feat(cli): filter discovery by ecosystem - #116
Open
justin13888 wants to merge 8 commits into
Open
Conversation
`--ecosystem` narrows the manifests `check`, `list`, and `fix` read to the ecosystems named, so one command can address one slice of a polyglot repository the way `--manifest-glob` addresses one slice of a monorepo. The flag this replaces parsed, was advertised in `--help` as restricting the run, and was read by nothing. Making the claim true takes two separate effects, and the load-bearing one is easy to miss: `dependable_fetch::discover` documents that its `enabled` predicate gates its unread-manifest notices only — discovery still returns every manifest it recognizes — so composing the request into that predicate alone would silence warnings and change nothing a command actually reads. `collect_manifests` therefore also filters the returned set, before the `--manifest-glob` filter so the glob's "matched nothing" line counts only what is still in play. Accepted values are the canonical ecosystem names and nothing else, so `--ecosystem foo` is now an error where the previous `Option<String>` took it silently. `CSharp` is spelled `csharp`, not clap's derived `c-sharp`. The flag only ever narrows: naming an ecosystem that `.dependable.toml` disables does not switch it back on. Selecting nothing names which ecosystems were searched and which were found instead, and exits 0 — an unused ecosystem must not fail a per-ecosystem CI matrix job.
This was referenced Sep 6, 2026
…into feat/105-ecosystem-filter
justin13888
added a commit
that referenced
this pull request
Sep 6, 2026
…feat/111-advance-forced-versions The two branches both rewrote how `fix` accounts for an update it does not write, from opposite ends: #116 made the accounting exhaustive (five new `DeclineReason` variants, `Declined::target` an `Option<String>`, a counted `report_inherited_skips`), and #122 gave the one kind that was still silent — an `overrides` / `resolutions` entry — a reason of its own and a flag that advances it. Resolved on the ordering rather than on the lines. `ForcedVersion` is now the last variant and the last guard in `plan_fixes`, because every reason above it would still stand with `--overrides` turned on: an override carrying a wildcard reports the wildcard, one already at its target reports the range, and one written as an explicit pin reports the pin, since `--all` and not `--overrides` is the flag that would move it. Every note names a flag that acts. The default is unchanged: `edits` still never receives an override unless `--overrides` was asked for by name, `--all` included.
The list of every variant existed already, but only inside this module's test module, where nothing outside the crate could reach it. A frontend that must cover every ecosystem therefore had to write its own copy, and a copy is not a guard: the hand that forgets a variant is the same hand that would have updated the copy. Promote it to a public associated const, beside the exhaustive matches that a new variant genuinely does not compile past, so the one list a new ecosystem cannot avoid meeting is also the list consumers pin against. The doc comment says plainly that this is a prompt rather than a proof — `#[non_exhaustive]` and the absence of stable variant enumeration leave no way to make the compiler check the list itself.
`every_ecosystem_can_be_named_on_the_command_line` compared
`EcosystemArg::value_variants()` to a nine-element list written a few
lines above it, in the same test module. Neither side referenced the
real variant set, so adding an ecosystem changed neither and the test
stayed green while `--ecosystem` lost the ability to name it — and
`Ecosystem` being `#[non_exhaustive]` means no match in this crate
breaks either, `Config::ecosystem_enabled`'s `_ => true` included.
Compare against `Ecosystem::ALL` instead. It lives beside the exhaustive
matches in the defining crate, which is where a new variant stops
compiling, so the author of the variant is already in that file when the
list asks to be updated — and once it is, this test goes red.
Both doc comments overstated what was enforced ("the unit test beside it
pins that every ecosystem crosses it", "A missing variant fails here
rather than at a user's prompt"). They now say what the assertion
actually rests on, and why a list local to the test would not do.
`collect_manifests` documents the order as load-bearing — "so the glob's 'matched nothing' line counts only the manifests still in play" — and nothing tested it. No test passed both `--ecosystem` and `--manifest-glob`; the `matched` helper passed `&[]` for `ecosystems` on every call. Swapping the two blocks is an easy move: both are a `filter`/`collect` over `found`. Swapped, `list . --ecosystem rust --manifest-glob 'nope/*'` over a Cargo+npm+Go tree prints two contradicting lines — `no manifest matched nope/* (searched 3 manifests ...)` counting manifests the ecosystem filter was about to remove, and then, `found` being empty by that point, `no manifest for Rust (searched 0 manifests ...)` about a tree that contains Rust. The order is invisible in the result, because both filters are set intersections and either order returns the same manifests. So it is pinned where it shows: an end-to-end assertion on the diagnostics. The unit test alongside it covers the other gap — that the two filters compose at all — and says in its own doc comment that it cannot pin the order. `sample-polyglot` is a new fixture rather than an edit to `sample-monorepo`, which is Rust throughout and so has nothing for an ecosystem filter to remove.
`run_list` reported the empty selection and returned before any JSON was constructed, so stdout was byte-empty. `list . --ecosystem csharp --format json | jq '.summary.projects'` — one shard per ecosystem, which is what this flag is for — then failed to parse on every shard whose ecosystem the repository does not use, which is precisely the set that exiting 0 was chosen to keep green. The stderr line saying what was searched is not machine-readable. Emit the empty `dependable.list/v1` document instead: same schema, zero projects, an empty `by_ecosystem`. Additive for any consumer, and a parseable answer beats no answer. `table` and `text` still return early. A human handed an empty table wants the stderr line, not a blank one. Both ways a selection can empty are covered — the ecosystem filter and the glob — because the byte-empty stdout predated `--ecosystem` and the glob path reached it too.
clap's `Vec<T>` with `ArgAction::Append` keeps repeats, so `--ecosystem rust --ecosystem rust` reached the empty-selection line as `no manifest for Rust, Rust (...)`. Dedupe by membership — the same `Vec::contains` pattern `no_ecosystem_match` already uses to list what was found instead, `Ecosystem` being `Eq` but not `Ord` — which also keeps the order the user named them in. Selection is untouched: it was already a `contains`, so a repeat never changed which manifests were kept. This is the diagnostic alone.
`report_no_manifests` claimed it is silent because `collect_manifests` "has already said which ecosystems were asked for and what was there instead". The suppression is keyed on `ecosystems.is_empty()` — on an ecosystem filter having been *asked for*, not on that filter being what emptied the set. `--ecosystem rust --manifest-glob 'nope/*'` over a repository that does contain Rust is emptied by the glob; the ecosystem explanation never printed, and the generic line was suppressed on a justification that had not occurred. The behaviour is right — the glob printed its own specific line, and "No supported manifests found." would still be false in a repository full of manifests a filter removed — so only the comment changes. It now describes what the predicate tests and says why it is the coarse one: a caller cannot tell the two cases apart without `collect_manifests` reporting back which filter emptied the set, and that return type stays `Vec<PathBuf>` on purpose.
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 #105.
--ecosystemnarrows the manifestscheck,list, andfixread to the ecosystems named, so one command can address one slice of a polyglot repository the way--manifest-globaddresses one slice of a monorepo.Stacked on #108 (
fix/93-report-declined-updates), the tip of master <- #99 <- #106 <- #108. Merge that first.What this actually changes
PR #99 removed a
--ecosystemflag that parsed, was advertised in--helpas restricting the run, and was read by nothing. The requirement it advertised is still open, and making the claim true takes two effects, not one:dependable_fetch::discover's own doc comment says itsenabledpredicate "gates the notices only — discovery still returns every manifest it recognizes, and narrowing that set stays the caller's job". Composing the request into that predicate alone would suppress unread-manifest warnings and change nothing a command reads — reinstating exactly the flag-that-lies. Socollect_manifestsfiltersfound.manifestsbyManifestKind::detect(p).ecosystem()as well, before the--manifest-globfilter, so the glob's "matched nothing" line counts only manifests still in play.discover, so--ecosystem rustdoes not print Gradle build-script advice about a build it just excluded.Both effects are pinned independently. Disabling the manifest-set filter fails 5 of the 8 new tests; dropping the notice composition fails the 6th.
Because the filter lives in
collect_manifests, there is one implementation and every caller gets the same behaviour.Behaviour to disclose
Option<String>-> a typed enum.--ecosystem foowas previously accepted silently and is now rejected. That is a real behaviour change, not a pure restoration.listandfixgaining the flag is new surface, not restoration. The removed flag existed onCheckArgsonly.--ecosystemdoes not override.dependable.toml's per-ecosystemenabled, and it registers no fetcher. Verified:check --ecosystem jvmagainst[jvm] enabled = falsediscovers the catalog and prints the existingskipping <path>: JVM is not enabled or not yet supported, exit 0.conflicts_with = "manifest", mirroring--manifest-glob.no manifest for Rust (searched 1 manifest up to --depth 3; found Elixir)— and the genericNo supported manifests found.is suppressed in that case, because it is a falsehood in a repository full of manifests the filter removed.report, which has no--ecosystem, prints the generic line exactly as before.reportdeliberately does not gain the flag.resolve_report_settings' doc comment states thatreporthas no--fail-on,--unstable,--formator--ecosystem, and it has no--manifest-globfor the stated reason that it describes a repository as a whole.collect_manifestsis private. No new exit code, config key, env var, or schema bump (dependable.list/v1unchanged); no SARIF or report change; no dependency change.Decisions taken
1. Deliverable boundary — which commands gain
--ecosystemin this changecheck,listandfix— the three commands the PRD specifies. Adds theecosystemfield to CheckArgs, ListArgs and FixArgs and passes the requested set at the run_check, run_list and run_fix call sites of collect_manifests.check+listonly — README states why the sibling--manifest-globflag exists onfix: "without it,dependable fixwould rewrite manifests that the matchingdependable checkdeliberately left out." That reasoning transfers verbatim, andfixis the command that writes to the user's files, so the asymmetry is the one with real consequences.report— the doc comment at runner.rs:968 states thatreportdeliberately has no--fail-on,--unstable,--formator--ecosystem, andreporthas no--manifest-globfor the same stated reason. Overturning a deliberate documented design statement is a separate argument this issue does not make.checkonly (strict restoration) —checkreaches the network on a match, so the narrowing could only be tested by asserting a negative. That is the same evidentiary weakness that let the original unread flag ship.ecosystemfield fromFixArgsand pass&[]at therun_fixcall site.reportis not a deferred remainder; its omission is a documented design decision in the code, recorded here rather than filed)2. Flag shape — repeatability and the value strings it accepts
Vec<EcosystemArg>with union semantics, canonical names only (rust go npm python php dart csharp elixir jvm), withCSharpgiven an explicit#[value(name = "csharp")]because clap's default kebab-casing would render itc-sharp. This is the exact shape of--manifest-glob, which README already documents as this repository's discovery-filtering idiom.kotlin/java/maven/gradle-> jvm,deno/pnpm/node-> npm,nuget/dotnet-> csharp, and so on) — every accepted string is a permanent compatibility surface, and a later ecosystem split (Deno gaining its own variant) would leavedenopointing at the wrong one forever. Aliases are additive and cheap to add later; they are not removable.--ecosystem rust,npm) —--manifest-globdeliberately does not take one, and diverging from the sibling flag needs a justification this issue does not supply.Option<EcosystemArg>— the issue's own motivating case is a monorepo, where "Rust and npm but not the rest" is the natural request.#[value(alias = "...")]attributes to EcosystemArg. For single-value, change the field toOption<EcosystemArg>and drop the union test.3. What an ecosystem that matches no discovered manifest does
no manifest matched {globs} (searched N manifests up to --depth {depth})line, and suppressing the genericNo supported manifests found.in that case..github/actions/dependable-check/action.ymldocuments exit 2 as "a tool error", which this is not; the direct precedent (an unmatched--manifest-glob) exits 0; and a non-zero exit breaks the per-ecosystem CI matrix job that is this flag's motivating use.No supported manifests found.is actively misleading in a repository full of manifests the filter removed, and the issue names this as the less honest of the two answers.collect_manifestsand let the generic line stand.4. The ledger on
--ecosystemhaving been removed by afix:-typed commit45a9742is not on origin/master,crates/dependable/CHANGELOG.md's## [Unreleased]is empty, and the workspace version is still 0.1.3.refactor!:) merges first, since a breaking-typed commit on master drives the 0.x minor bump on its own.45a9742's message tofix!:— this run may not force-push or rewrite published history, and that commit is pushed. Unavailable.Option<String>-> a typed enum, so--ecosystem foowas previously accepted silently and is now rejected. That is a real behaviour change landing under afeat:commit, and it is stated here rather than left for a reader to find.5. Where the generic empty-discovery line is decided (taken during implementation)
report_no_manifests(&[Ecosystem])helper at each call site, silent when an ecosystem filter is active.collect_manifestskeeps returningVec<PathBuf>, so the only signature change is the recordedecosystems: &[Ecosystem]parameter.collect_manifeststo return a struct carrying an "already explained" flag — a second, unrecorded signature change for a one-line decision three callers can make from an argument they already hold.--manifest-globpath does today — decision 3 rules that out for--ecosystem; the glob path is left exactly as it was rather than widened into.eprintln!("No supported manifests found.")at the four call sites and delete the helper.6. Aliases for the accepted
--ecosystemvalues (kotlin,java,maven,gradle,node,deno,pnpm,nuget,dotnet)java/kotlin->jvmanddotnet->csharpnow. The usability gap is real —jvmis not the first word a Kotlin user reaches for — but it is recoverable, and an alias that later points at the wrong variant is not.#[value(alias = "...")]attributes toEcosystemArg.7. An empty selection under
--format json(taken during review repair)dependable.list/v1document — same schema,summary.projects: 0, emptyby_ecosystem, emptyprojects.run_listpreviously reported the empty selection and returned before any document was built, so stdout was byte-empty andlist . --ecosystem csharp --format json | jq '.summary.projects'failed to parse on every matrix shard whose ecosystem the repository does not use — precisely the set that exiting 0 was chosen to keep green. Schema-compatible and additive: a consumer gets a parseable document with zero projects rather than nothing.list --ecosystem ... --format jsonmatrix use, so it ships the recommendation that walks into it.tableandtext— a human reading an empty table wants the stderr line, not an empty table.Tests
New
crates/dependable/tests/cli_ecosystem.rs(8 tests, hermetic —CARGO_TARGET_TMPDIRscratch dirs, no new fixture directories, no network):ecosystem_narrows_the_inventory_to_what_was_asked_forlist --format json --ecosystem rustover a Cargo+npm+Go tree returns only the Cargo project andsummary.by_ecosystemholds only Rust. The removed flag would have failed this.an_ecosystem_covers_every_manifest_spelling_it_owns--ecosystem npmreturnspackage.json,deno.jsonandpnpm-workspace.yaml, notCargo.toml.two_ecosystems_are_a_union_not_a_contradictioncheck_narrows_discovery_without_touching_the_networkecosystem_and_manifest_are_mutually_exclusive--ecosystem.the_flag_is_advertised_with_the_values_it_acceptsgocannot pass oncargo.an_ecosystem_filter_silences_advice_about_ecosystems_it_excludedfix_rewrites_only_the_ecosystem_it_was_pointed_atfix --dry-run --ecosystem rustconsiders no npm or Go manifest and leaves both byte-identical. Hermetic by construction: the only Rust manifest declares no dependencies, so the narrowed run has nothing to look up — afixthat ignored the flag is also the one that would reach a registry.A ninth test,
the_ecosystem_filter_runs_before_the_glob_filter, was added during review repair — see below.Plus
cli.rs::every_ecosystem_can_be_named_on_the_command_line, which asserts the image ofEcosystemArg::value_variants()underFromis exactlyEcosystem::ALL, andcsharp_is_spelled_the_way_it_is_typed, which pins the value strings clap advertises.Review repairs
Five findings from review, repaired on this branch as six commits.
1. The exhaustiveness guard compared
cli.rstocli.rs(medium)every_ecosystem_can_be_named_on_the_command_linecomparedEcosystemArg::value_variants()against a nine-elementALLwritten a few lines above it, in the same test module. Neither side referenced the real variant set. AddingEcosystem::Swiftchanged neither, so the test stayed green while--ecosystem swiftbecame unspellable,--helpstopped advertising it, andcheck . --ecosystem rustsilently excluded Swift manifests with no way to opt them back in. Nothing downstream caught it either:Ecosystemis#[non_exhaustive], so no match incrates/dependableis exhaustive —Config::ecosystem_enabled's_ => trueincluded. Two doc comments claimed enforcement that did not exist ("the unit test beside it pins that every ecosystem crosses it"; "A missing variant fails here rather than at a user's prompt").Fixed by promoting
dependable-core's test-onlyALLto a publicEcosystem::ALL, placed beside the exhaustive matches in the defining crate — the file a new variant genuinely does not compile past — and asserting the CLI's nameable set equals it. Both doc comments now claim only what is enforced.Demonstrated, by adding a tenth variant
Ecosystem::Zig:cargo check -p dependable-coreerror[E0004]: non-exhaustive patterns: 'Ecosystem::Zig' not covered— 6 errors, every one inecosystem.rs, which is what puts the author in the file whereALLlivesALL, thencargo test -p dependable --bin dependableevery_ecosystem_can_be_named_on_the_command_line ... FAILED—left: [.., Jvm] right: [.., Jvm, Zig]cli.rsrestored to this branch's previous versionevery_ecosystem_can_be_named_on_the_command_line ... ok— the old guard is green with a tenth ecosystem present and unnameableHonest limit, stated on
ALLitself: this is a prompt, not a proof.#[non_exhaustive]and the absence of stable variant enumeration mean nothing forces the list to grow — only the six compile errors, which land in the same file.This is the third instance of this class in the repository. #124 (
ALL_KINDS) and #145 (PackageSource->source_token) are the other two, and are deliberately not touched here.2. The load-bearing ordering invariant had no test (medium)
collect_manifestsdocuments the ecosystem filter running before the glob filter "so that the glob's 'matched nothing' line counts only the manifests still in play", and nothing tested it. No test passed both flags; thematched()helper passed&[]forecosystemson every call.Both filters are set intersections, so the returned set cannot distinguish the two orders — a unit test on the return value provably cannot fail on a swap. The order is observable only in the diagnostics, so that is where it is pinned:
the_ecosystem_filter_runs_before_the_glob_filterasserts stderr end to end. The unit test alongside it (an_ecosystem_and_a_glob_narrow_the_same_set) covers the other gap — that the two filters compose at all — and says in its own doc comment that it cannot pin the order.Demonstrated by swapping the two blocks in
collect_manifests:and, on
list . --ecosystem rust --manifest-glob 'nope/*'over a Cargo+npm+Go tree, exactly the two contradicting lines the finding predicted:The swap was reverted; the branch carries the correct order.
crates/dependable/tests/fixtures/sample-polyglot/is a new fixture (a Rust service beside a Go one) rather than an edit tosample-monorepo, which is Rust throughout and so has nothing for an ecosystem filter to remove.3. Empty selection wrote byte-empty stdout under
--format json(medium)Pre-existing, but promoted into reach by this change's own README. Fixed as decision 7 above. Covered by
an_empty_selection_is_an_empty_document_not_empty_stdout, which asserts the document for both ways a selection empties — the ecosystem filter and the glob — and thattableandtextstill print nothing.4.
--ecosystem rust --ecosystem rustsaidno manifest for Rust, Rust(low)clap's
Vec<T>withArgAction::Appendkeeps repeats. Deduplicated by membership inrequested_ecosystems, the sameVec::containspatternno_ecosystem_matchalready uses (EcosystemisEqbut notOrd), which also preserves first-named order. Selection was never affected — it was already acontains— so this is the diagnostic alone. Pinned bya_repeated_ecosystem_is_named_once.5.
report_no_manifests' comment described a justification that need not have occurred (low)The comment said the generic line is suppressed because
collect_manifests"has already said which ecosystems were asked for and what was there instead". The suppression is keyed onecosystems.is_empty()— on a filter having been asked for, not on that filter being what emptied the set. With--ecosystem rust --manifest-glob 'nope/*'over a repository that does contain Rust, the glob empties it and the ecosystem explanation never prints.Comment only; no behaviour change. The behaviour is right — the glob printed its own specific line, and "No supported manifests found." would still be false in a repository full of manifests a filter removed. The return type stays
Vec<PathBuf>, as decision 5 already settled.Commits
Ecosystem::ALLEcosystem::ALLlist --format jsonselection a documentThe only public API change in a library crate is the addition of
Ecosystem::ALL— additive, and re-exported throughdependable-fetchalongside the type. No exit code, config key, env var or schema version changes;dependable.list/v1is unchanged and now also emitted where nothing was emitted before.Validation
All run from the worktree with
env -u FORCE_COLOR -u COLORTERM, on905259a.cargo test -p dependable --test cli_ecosystemcargo test -p dependable --test cli_list#[ignore = "network"])cargo test -p dependable --bin dependablecargo test -p dependable-core ecosystemmise run testmise run fmt:check(cargo fmt --all --check)mise run lint(cargo clippy --workspace --all-targets -- -D warnings)convco check origin/fix/93-report-declined-updates..HEADno errors in 7 commitsMutation checks, to prove the tests are not vacuous:
cli_ecosystemtests fail.&& requested(ecosystem)from the notice predicate:an_ecosystem_filter_silences_advice_about_ecosystems_it_excludedfails, alone.Ecosystemvariant: 6 compile errors inecosystem.rs, thenevery_ecosystem_can_be_named_on_the_command_linefails. Under the previous guard, the same tree was green.the_ecosystem_filter_runs_before_the_glob_filterfails.Not proven, and worth a reviewer's eye:
Ecosystem::ALLis checked by convention and by six compile errors in one file, not by the compiler checking the list itself. A macro defining the enum and the list together would be airtight; it was not taken, because it would restructure a heavily documented enum for a guard that already lands the author in the right file.list --format jsonrenders an absoluterootwith a leading//("root": "//var/mnt/..."). Pre-existing inoutput::posix, reproduced on this branch's base with a non-empty selection, and untouched here.