Skip to main content

deps_lsp/
lib.rs

1pub mod config;
2pub mod document;
3pub mod file_watcher;
4pub mod handlers;
5pub mod progress;
6pub mod server;
7
8#[cfg(test)]
9mod test_utils;
10
11use std::sync::Arc;
12use std::sync::atomic::AtomicBool;
13
14pub use deps_core::{DepsError, EcosystemRegistry, HttpCache, Result};
15pub use server::Backend;
16
17/// Live-updatable settings [`register_ecosystems`] threads into every ecosystem that needs them.
18///
19/// Bundled into one struct (issue #561, M3) rather than growing that function's arity again
20/// for each new cross-ecosystem live flag.
21#[derive(Debug, Clone)]
22pub struct EcosystemRuntime {
23    /// Gates every workspace-declared registry index host (spec #443,
24    /// `registries.workspace_registries`).
25    pub policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
26    /// `registries.nuget_user_profile_sources` (issue #561, FR-006) — whether a NuGet
27    /// user-profile-tier `NuGet.Config` source with no repo-declared counterpart becomes a
28    /// routing hop, not just a credential source. Default `false`.
29    pub nuget_user_profile_sources: Arc<AtomicBool>,
30    /// `registries.gitlab_instance_host` (issue #466, spec FR-005a/FR-011a) — the raw
31    /// configured GitLab instance host string, or `None` when unset. A feature-agnostic
32    /// `Arc<RwLock<Option<String>>>` (not a `deps-gitlab-ci` type) since this struct is
33    /// un-`cfg`'d — see `deps_gitlab_ci::host::GitlabInstanceHost`'s docs for why host
34    /// validation lives in that crate instead, applied on read.
35    pub gitlab_instance_host: Arc<std::sync::RwLock<Option<String>>>,
36}
37
38/// Declares an ecosystem: re-exports types and registers at runtime.
39macro_rules! ecosystem {
40    ($feature:literal, $crate_name:ident, $ecosystem:ident, [$($types:ident),* $(,)?]) => {
41        #[cfg(feature = $feature)]
42        pub use $crate_name::{$ecosystem, $($types),*};
43    };
44}
45
46/// Registers ecosystem if feature is enabled.
47macro_rules! register {
48    ($feature:literal, $ecosystem:ident, $registry:expr, $cache:expr) => {
49        #[cfg(feature = $feature)]
50        $registry.register(Arc::new($ecosystem::new(Arc::clone($cache))));
51    };
52}
53
54// =============================================================================
55// Ecosystems — to add new: 1) feature in Cargo.toml  2) add ecosystem!() + register!()
56// =============================================================================
57
58ecosystem!(
59    "cargo",
60    deps_cargo,
61    CargoEcosystem,
62    [
63        CargoParser,
64        CargoVersion,
65        CrateInfo,
66        CratesIoRegistry,
67        DependencySection,
68        DependencySource,
69        ParseResult,
70        ParsedDependency,
71        parse_cargo_toml,
72    ]
73);
74
75ecosystem!(
76    "npm",
77    deps_npm,
78    NpmEcosystem,
79    [
80        NpmDependency,
81        NpmDependencySection,
82        NpmPackage,
83        NpmParseResult,
84        NpmRegistry,
85        NpmVersion,
86        parse_package_json,
87    ]
88);
89
90ecosystem!(
91    "pypi",
92    deps_pypi,
93    PypiEcosystem,
94    [
95        PypiDependency,
96        PypiDependencySection,
97        PypiParser,
98        PypiRegistry,
99        PypiVersion,
100    ]
101);
102
103ecosystem!(
104    "go",
105    deps_go,
106    GoEcosystem,
107    [
108        GoDependency,
109        GoDirective,
110        GoParseResult,
111        GoRegistry,
112        GoVersion,
113        parse_go_mod,
114    ]
115);
116
117ecosystem!(
118    "bundler",
119    deps_bundler,
120    BundlerEcosystem,
121    [
122        BundlerDependency,
123        BundlerParseResult,
124        BundlerVersion,
125        DependencyGroup,
126        GemInfo,
127        GemfileLockParser,
128        RubyGemsRegistry,
129        parse_gemfile,
130    ]
131);
132
133ecosystem!(
134    "dart",
135    deps_dart,
136    DartEcosystem,
137    [
138        DartDependency,
139        DartParseResult,
140        DartVersion,
141        DartFormatter,
142        PackageInfo,
143        PubDevRegistry,
144        PubspecLockParser,
145        parse_pubspec_yaml,
146    ]
147);
148
149ecosystem!(
150    "maven",
151    deps_maven,
152    MavenEcosystem,
153    [
154        MavenDependency,
155        MavenParseResult,
156        MavenVersion,
157        MavenFormatter,
158        ArtifactInfo,
159        MavenCentralRegistry,
160        parse_pom_xml,
161    ]
162);
163
164ecosystem!(
165    "gradle",
166    deps_gradle,
167    GradleEcosystem,
168    [
169        GradleDependency,
170        GradleParseResult,
171        GradleVersion,
172        GradleFormatter,
173        parse_gradle,
174    ]
175);
176
177ecosystem!(
178    "swift",
179    deps_swift,
180    SwiftEcosystem,
181    [
182        SwiftDependency,
183        SwiftParseResult,
184        SwiftVersion,
185        SwiftPackage,
186        SwiftFormatter,
187        SwiftRegistry,
188        SwiftLockParser,
189        parse_package_swift,
190    ]
191);
192
193ecosystem!(
194    "composer",
195    deps_composer,
196    ComposerEcosystem,
197    [
198        ComposerDependency,
199        ComposerSection,
200        ComposerPackage,
201        ComposerParseResult,
202        PackagistRegistry,
203        ComposerVersion,
204        parse_composer_json,
205    ]
206);
207
208// Note: `PackageInfo` is deliberately omitted from this re-export list — it collides with
209// `deps_dart::PackageInfo`, already re-exported above. Reachable directly as
210// `deps_nuget::PackageInfo` for anything that needs it.
211ecosystem!(
212    "nuget",
213    deps_nuget,
214    NuGetEcosystem,
215    [
216        NuGetDependency,
217        NuGetParseResult,
218        NuGetVersion,
219        NuGetFormatter,
220        NuGetRegistry,
221        NuGetLockParser,
222        parse_project_file,
223    ]
224);
225
226ecosystem!(
227    "deno",
228    deps_deno,
229    DenoEcosystem,
230    [
231        DenoDependency,
232        DenoDependencySection,
233        DenoFormatter,
234        DenoMetadata,
235        DenoParseResult,
236        DenoRegistry,
237        JsrPackage,
238        JsrRegistry,
239        JsrVersion,
240        parse_deno_json,
241    ]
242);
243
244ecosystem!(
245    "github-actions",
246    deps_github_actions,
247    GithubActionsEcosystem,
248    [
249        GithubActionsDependency,
250        GithubActionsFormatter,
251        GithubActionsParseResult,
252        GithubActionsRegistry,
253        GithubActionsVersion,
254        parse_workflow_yaml,
255    ]
256);
257
258ecosystem!(
259    "gitlab-ci",
260    deps_gitlab_ci,
261    GitlabCiEcosystem,
262    [
263        GitlabCiDependency,
264        GitlabCiFormatter,
265        GitlabCiParseResult,
266        GitlabCiRegistry,
267        GitlabCiVersion,
268        parse_gitlab_ci_yaml,
269    ]
270);
271
272/// Registers all enabled ecosystems.
273///
274/// `cargo` is special-cased (spec #443/#441, plan-1b §1.6): unlike `register!`'s generic
275/// `Ecosystem::new(cache)` call, `CargoEcosystem` needs `policy` threaded through
276/// `CargoEcosystem::with_context` so `ServerState`'s live-updatable
277/// `Arc<RegistryAccessPolicy>` (see `document::state::ServerState::registry_policy`) is the
278/// exact same handle every Cargo parse reads — `initialize`/`did_change_configuration`
279/// updating it then takes effect immediately, with no need to reconstruct the ecosystem.
280///
281/// `npm` and `deno` are special-cased (#312): when both features are enabled, they share
282/// one `NpmRegistry` instance — built once here and handed to both `NpmEcosystem` and
283/// `DenoEcosystem`'s `npm:`-scheme half via `with_registry`/`with_npm` — instead of each
284/// constructing its own. `NpmRegistry` is cheaply `Clone` (its `HttpCache` and
285/// freshness-path publish-time map are both `Arc`-wrapped internally), so this dedupes the
286/// freshness path's full-packument fetch and its publish-time cache for a package
287/// appearing in both `package.json` and a `deno.json` `npm:`-specifier dependency, on top
288/// of the plain cached GETs the shared `cache` already dedupes.
289/// Returns every ecosystem id this call threaded the live `RegistryAccessPolicy` handle
290/// into (issue #592 security M1) — the single source of truth `config::reparse_scope`'s
291/// caller consults to scope a `registries.workspace_registries` reparse, so that set can
292/// never drift from what this function actually wires up. Adding a 6th policy-consuming
293/// ecosystem means editing this function anyway (to thread `policy` through its parse
294/// context); pushing its id onto the returned list at that same call site keeps the two
295/// facts — "receives the policy" and "is in the reparse scope" — physically inseparable,
296/// rather than duplicated across two independently-editable places.
297pub fn register_ecosystems(
298    registry: &EcosystemRegistry,
299    cache: Arc<HttpCache>,
300    runtime: &EcosystemRuntime,
301) -> Vec<&'static str> {
302    let policy = Arc::clone(&runtime.policy);
303    // Keeps `policy` used even when the `cargo` feature (its only consumer) is compiled out.
304    let _ = &policy;
305    let mut workspace_registry_ecosystems = Vec::new();
306
307    #[cfg(feature = "cargo")]
308    {
309        let context = deps_cargo::parser::CargoParseContext {
310            policy: Arc::clone(&policy),
311            config_cache: Arc::new(deps_cargo::config::ConfigFileCache::new()),
312        };
313        registry.register(Arc::new(CargoEcosystem::with_context(
314            Arc::clone(&cache),
315            context,
316        )));
317        workspace_registry_ecosystems.push("cargo");
318    }
319
320    #[cfg(all(feature = "npm", feature = "deno"))]
321    {
322        let npm_context = deps_npm::config::NpmParseContext {
323            policy: Arc::clone(&policy),
324            config_cache: Arc::new(deps_npm::config::NpmConfigCache::new()),
325            workspace_cache: Arc::new(deps_npm::catalog::PnpmWorkspaceCache::new()),
326        };
327        let npm_registry = Arc::new(NpmRegistry::new(Arc::clone(&cache)));
328        registry.register(Arc::new(NpmEcosystem::with_context(
329            Arc::clone(&npm_registry),
330            npm_context,
331        )));
332        workspace_registry_ecosystems.push("npm");
333        // `DenoEcosystem::with_npm` shares the registry above but is never handed `policy`
334        // itself (its own `.npmrc`-style workspace registry concept doesn't exist yet), so
335        // "deno" deliberately never joins this list.
336        registry.register(Arc::new(DenoEcosystem::with_npm(
337            Arc::clone(&cache),
338            npm_registry.as_ref().clone(),
339        )));
340    }
341    // npm is written out explicitly rather than via `register!` (spec 032, S3): that macro's
342    // `NpmEcosystem::new(cache)` would give npm a default, disconnected `NpmParseContext` —
343    // its `.npmrc` reachability policy would never see a live `initialize`/
344    // `didChangeConfiguration` update.
345    #[cfg(all(feature = "npm", not(feature = "deno")))]
346    {
347        let npm_context = deps_npm::config::NpmParseContext {
348            policy: Arc::clone(&policy),
349            config_cache: Arc::new(deps_npm::config::NpmConfigCache::new()),
350            workspace_cache: Arc::new(deps_npm::catalog::PnpmWorkspaceCache::new()),
351        };
352        registry.register(Arc::new(NpmEcosystem::with_context(
353            Arc::new(NpmRegistry::new(Arc::clone(&cache))),
354            npm_context,
355        )));
356        workspace_registry_ecosystems.push("npm");
357    }
358    #[cfg(all(feature = "deno", not(feature = "npm")))]
359    register!("deno", DenoEcosystem, registry, &cache);
360
361    // pypi is written out explicitly rather than via `register!` (spec 033, mirroring npm's
362    // spec 032 S3 precedent): that macro's `PypiEcosystem::new(cache)` would give pypi a
363    // default, disconnected `RegistryAccessPolicy` — its private-index reachability policy
364    // would never see a live `initialize`/`didChangeConfiguration` update.
365    #[cfg(feature = "pypi")]
366    {
367        registry.register(Arc::new(PypiEcosystem::with_policy(
368            Arc::new(PypiRegistry::new(Arc::clone(&cache))),
369            Arc::clone(&policy),
370        )));
371        workspace_registry_ecosystems.push("pypi");
372    }
373
374    // go is written out explicitly rather than via `register!` (spec 034, mirroring npm's
375    // spec 032 S3 precedent): that macro's `GoEcosystem::new(cache)` would give Go a
376    // default, disconnected `GoParseContext` — its `$GOENV` reachability policy would never
377    // see a live `initialize`/`didChangeConfiguration` update.
378    #[cfg(feature = "go")]
379    {
380        let go_context = deps_go::config::GoParseContext {
381            policy: Arc::clone(&policy),
382            config_cache: Arc::new(deps_go::config::GoEnvCache::new()),
383            goenv_path: deps_go::config::goenv_path(),
384        };
385        registry.register(Arc::new(GoEcosystem::with_context(
386            Arc::new(GoRegistry::new(Arc::clone(&cache))),
387            go_context,
388        )));
389        workspace_registry_ecosystems.push("go");
390    }
391    register!("bundler", BundlerEcosystem, registry, &cache);
392    register!("dart", DartEcosystem, registry, &cache);
393    register!("maven", MavenEcosystem, registry, &cache);
394    register!("gradle", GradleEcosystem, registry, &cache);
395    register!("swift", SwiftEcosystem, registry, &cache);
396    register!("composer", ComposerEcosystem, registry, &cache);
397
398    // nuget is written out explicitly rather than via `register!` (issue #523, mirroring
399    // npm's/pypi's identical precedent): that macro's `NuGetEcosystem::new(cache)` would give
400    // nuget a default, disconnected `RegistryAccessPolicy` — its private-feed reachability
401    // policy would never see a live `initialize`/`didChangeConfiguration` update.
402    #[cfg(feature = "nuget")]
403    {
404        let nuget_context = deps_nuget::config::NuGetParseContext::new(
405            Arc::clone(&policy),
406            Arc::new(deps_nuget::config::NuGetConfigCache::new()),
407            Arc::clone(&runtime.nuget_user_profile_sources),
408        );
409        registry.register(Arc::new(NuGetEcosystem::with_context(
410            Arc::new(NuGetRegistry::new(Arc::clone(&cache))),
411            nuget_context,
412        )));
413        workspace_registry_ecosystems.push("nuget");
414    }
415
416    register!("github-actions", GithubActionsEcosystem, registry, &cache);
417
418    // gitlab-ci is written out explicitly rather than via `register!` (issue #466, mirroring
419    // github-actions'/nuget's identical precedent): that macro's `GitlabCiEcosystem::new(cache)`
420    // would give it a default, disconnected `registries.gitlab_instance_host` — its
421    // self-hosted-instance resolution and the single token-host rule (spec FR-005a/FR-011a)
422    // would never see a live `initialize`/`didChangeConfiguration` update.
423    #[cfg(feature = "gitlab-ci")]
424    registry.register(Arc::new(GitlabCiEcosystem::with_context(
425        Arc::clone(&cache),
426        Arc::clone(&policy),
427        Arc::clone(&runtime.gitlab_instance_host),
428    )));
429
430    workspace_registry_ecosystems
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    fn test_runtime() -> EcosystemRuntime {
438        EcosystemRuntime {
439            policy: Arc::new(deps_core::net_policy::RegistryAccessPolicy::default()),
440            nuget_user_profile_sources: Arc::new(AtomicBool::new(false)),
441            gitlab_instance_host: Arc::new(std::sync::RwLock::new(None)),
442        }
443    }
444
445    #[test]
446    fn test_register_ecosystems() {
447        let registry = Arc::new(EcosystemRegistry::new());
448        let cache = Arc::new(HttpCache::new());
449        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
450
451        #[cfg(feature = "cargo")]
452        assert!(registry.get("cargo").is_some());
453        #[cfg(feature = "npm")]
454        assert!(registry.get("npm").is_some());
455        #[cfg(feature = "pypi")]
456        assert!(registry.get("pypi").is_some());
457        #[cfg(feature = "go")]
458        assert!(registry.get("go").is_some());
459        #[cfg(feature = "bundler")]
460        assert!(registry.get("bundler").is_some());
461        #[cfg(feature = "dart")]
462        assert!(registry.get("dart").is_some());
463        #[cfg(feature = "maven")]
464        assert!(registry.get("maven").is_some());
465        #[cfg(feature = "gradle")]
466        assert!(registry.get("gradle").is_some());
467        #[cfg(feature = "swift")]
468        assert!(registry.get("swift").is_some());
469        #[cfg(feature = "composer")]
470        assert!(registry.get("composer").is_some());
471        #[cfg(feature = "nuget")]
472        assert!(registry.get("nuget").is_some());
473        #[cfg(feature = "deno")]
474        assert!(registry.get("deno").is_some());
475        #[cfg(feature = "github-actions")]
476        assert!(registry.get("github-actions").is_some());
477        #[cfg(feature = "gitlab-ci")]
478        assert!(registry.get("gitlab-ci").is_some());
479    }
480
481    /// Issue #592 security M1: every id `register_ecosystems` returns must actually be a
482    /// registered ecosystem (catches a typo'd `push` literal) and, for this feature set,
483    /// must exactly match the five ecosystems known to thread `RegistryAccessPolicy` through
484    /// their parse context — a regression here means either a policy-consuming ecosystem
485    /// was added without pushing its id (fails closed for `config::reparse_scope`), or an id
486    /// was pushed for an ecosystem that no longer receives the policy (harmless over-scoping,
487    /// but signals the two facts drifted anyway).
488    #[test]
489    #[allow(
490        clippy::vec_init_then_push,
491        reason = "each push is independently feature-gated, so a `vec![]` literal can't \
492                  express the feature-conditional membership"
493    )]
494    fn test_register_ecosystems_workspace_registry_list_matches_registered_ecosystems() {
495        let registry = Arc::new(EcosystemRegistry::new());
496        let cache = Arc::new(HttpCache::new());
497        let workspace_registry_ecosystems =
498            register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
499
500        for id in &workspace_registry_ecosystems {
501            assert!(
502                registry.get(id).is_some(),
503                "{id:?} was returned as policy-consuming but is not a registered ecosystem"
504            );
505        }
506
507        let mut expected = Vec::new();
508        #[cfg(feature = "cargo")]
509        expected.push("cargo");
510        #[cfg(feature = "npm")]
511        expected.push("npm");
512        #[cfg(feature = "pypi")]
513        expected.push("pypi");
514        #[cfg(feature = "go")]
515        expected.push("go");
516        #[cfg(feature = "nuget")]
517        expected.push("nuget");
518        expected.sort_unstable();
519        let mut actual = workspace_registry_ecosystems.clone();
520        actual.sort_unstable();
521        assert_eq!(
522            actual, expected,
523            "workspace-registry-policy ecosystem set changed — update this test's `expected` \
524             list alongside whatever registration change caused it"
525        );
526    }
527
528    /// Regression guard for issue #118: `EcosystemId`'s string literals (`deps-core`)
529    /// are hand-duplicated from each ecosystem crate's own `Ecosystem::id()`, with
530    /// nothing linking them at compile time. This proves every id actually registered
531    /// by `register_ecosystems` round-trips through `EcosystemId::from_str`/`id()`,
532    /// and that every `EcosystemId` variant resolves back to a registered ecosystem —
533    /// so a future rename fails this test instead of panicking at document-open time
534    /// (see the `.expect()` in `document::lifecycle::resolve_ecosystem_id`).
535    #[test]
536    fn test_ecosystem_id_matches_registered_ecosystems() {
537        let registry = Arc::new(EcosystemRegistry::new());
538        let cache = Arc::new(HttpCache::new());
539        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
540
541        for id in registry.ecosystem_ids() {
542            let parsed: deps_core::EcosystemId = id.parse().unwrap_or_else(|_| {
543                panic!("registered ecosystem id {id:?} has no matching EcosystemId variant")
544            });
545            assert_eq!(parsed.id(), id);
546        }
547
548        #[cfg(feature = "cargo")]
549        assert!(registry.get(deps_core::EcosystemId::Cargo.id()).is_some());
550        #[cfg(feature = "npm")]
551        assert!(registry.get(deps_core::EcosystemId::Npm.id()).is_some());
552        #[cfg(feature = "pypi")]
553        assert!(registry.get(deps_core::EcosystemId::Pypi.id()).is_some());
554        #[cfg(feature = "go")]
555        assert!(registry.get(deps_core::EcosystemId::Go.id()).is_some());
556        #[cfg(feature = "bundler")]
557        assert!(registry.get(deps_core::EcosystemId::Bundler.id()).is_some());
558        #[cfg(feature = "dart")]
559        assert!(registry.get(deps_core::EcosystemId::Dart.id()).is_some());
560        #[cfg(feature = "maven")]
561        assert!(registry.get(deps_core::EcosystemId::Maven.id()).is_some());
562        #[cfg(feature = "gradle")]
563        assert!(registry.get(deps_core::EcosystemId::Gradle.id()).is_some());
564        #[cfg(feature = "swift")]
565        assert!(registry.get(deps_core::EcosystemId::Swift.id()).is_some());
566        #[cfg(feature = "composer")]
567        assert!(
568            registry
569                .get(deps_core::EcosystemId::Composer.id())
570                .is_some()
571        );
572        #[cfg(feature = "nuget")]
573        assert!(registry.get(deps_core::EcosystemId::NuGet.id()).is_some());
574        #[cfg(feature = "deno")]
575        assert!(registry.get(deps_core::EcosystemId::Deno.id()).is_some());
576        #[cfg(feature = "github-actions")]
577        assert!(
578            registry
579                .get(deps_core::EcosystemId::GithubActions.id())
580                .is_some()
581        );
582        #[cfg(feature = "gitlab-ci")]
583        assert!(
584            registry
585                .get(deps_core::EcosystemId::GitlabCi.id())
586                .is_some()
587        );
588    }
589
590    /// #348 regression: `select_latest_matching` must resolve an all-`AdvisoryDeprecated`
591    /// version list under a wildcard requirement for every registered ecosystem — an
592    /// advisory-only flag (npm `deprecated`, Composer `abandoned`, ...) must never make an
593    /// existing package look unresolvable (#347). Iterates every id `register_ecosystems`
594    /// wires up via `EcosystemRegistry::ecosystem_ids`, so a 12th ecosystem is covered
595    /// automatically without a new test. The paired `Available` control guards against a
596    /// `None` result that has nothing to do with the advisory flag (e.g. the fixture
597    /// version strings not fitting this ecosystem's matcher).
598    ///
599    /// This assertion is only genuinely discriminating for an ecosystem whose
600    /// `select_latest_matching` actually consults `removal_status()` when filtering under
601    /// a wildcard requirement (currently Composer, npm, and Deno-via-npm) — for an
602    /// ecosystem that doesn't filter on it at all, or that only maps a real per-version
603    /// yank (not the advisory case), `subject.is_some()` is trivially true regardless of
604    /// whether the ecosystem maps its advisory flag correctly.
605    #[test]
606    fn test_select_latest_matching_resolves_advisory_deprecated_for_every_ecosystem() {
607        use deps_core::{RemovalStatus, Version, VersionReq};
608        use std::any::Any;
609
610        struct StatusVersion {
611            version: deps_core::ConcreteVersion,
612            status: RemovalStatus,
613        }
614
615        impl Version for StatusVersion {
616            fn version_string(&self) -> &deps_core::ConcreteVersion {
617                &self.version
618            }
619
620            fn removal_status(&self) -> RemovalStatus {
621                self.status
622            }
623
624            fn as_any(&self) -> &dyn Any {
625                self
626            }
627        }
628
629        fn fixture(status: RemovalStatus) -> Vec<Box<dyn Version>> {
630            vec![
631                Box::new(StatusVersion {
632                    version: "2.0.0".into(),
633                    status,
634                }),
635                Box::new(StatusVersion {
636                    version: "1.2.3".into(),
637                    status,
638                }),
639            ]
640        }
641
642        // #421 S2: a package whose only releases so far are all prerelease must still
643        // resolve under a wildcard requirement, same as an all-`AdvisoryDeprecated` one
644        // above — a prerelease-only flag is a ranking preference for "latest", not a hard
645        // removal from existence. `is_prerelease()` is overridden directly rather than
646        // relying on a hyphenated version string, so this fixture is unambiguous regardless
647        // of which ecosystem-specific parser (if any) `select_latest_matching` re-parses
648        // `version_string()` with.
649        struct PrereleaseOnlyVersion {
650            version: deps_core::ConcreteVersion,
651        }
652
653        impl Version for PrereleaseOnlyVersion {
654            fn version_string(&self) -> &deps_core::ConcreteVersion {
655                &self.version
656            }
657
658            fn is_prerelease(&self) -> bool {
659                true
660            }
661
662            fn as_any(&self) -> &dyn Any {
663                self
664            }
665        }
666
667        fn prerelease_only_fixture() -> Vec<Box<dyn Version>> {
668            vec![
669                Box::new(PrereleaseOnlyVersion {
670                    version: "2.0.0-beta2".into(),
671                }),
672                Box::new(PrereleaseOnlyVersion {
673                    version: "2.0.0-beta1".into(),
674                }),
675            ]
676        }
677
678        let registry = Arc::new(EcosystemRegistry::new());
679        let cache = Arc::new(HttpCache::new());
680        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
681
682        let req = VersionReq::new("*");
683        for id in registry.ecosystem_ids() {
684            let ecosystem = registry.get(id).expect("id came from ecosystem_ids()");
685            let ecosystem_registry = ecosystem.registry();
686
687            let control =
688                ecosystem_registry.select_latest_matching(&fixture(RemovalStatus::Available), &req);
689            assert!(
690                control.is_some(),
691                "{id}: control fixture (all Available) must resolve under a wildcard \
692                 requirement — a `None` here means the fixture itself doesn't fit this \
693                 ecosystem's matcher, not that the advisory flag broke anything"
694            );
695
696            let subject = ecosystem_registry
697                .select_latest_matching(&fixture(RemovalStatus::AdvisoryDeprecated), &req);
698            assert!(
699                subject.is_some(),
700                "{id}: an advisory-only flag must not hide an existing package under a \
701                 wildcard requirement (#347)"
702            );
703
704            // Go is a deliberate exception to this invariant, not an #421-class bug
705            // (documented at #364): `select_latest_matching` intentionally excludes
706            // prerelease pseudo-versions unconditionally, with no wildcard fallback, so the
707            // `/@v/list`-based pick never shadows the `/@latest` fallback the fetch loop
708            // needs for a module whose only tags are prerelease. Asserting this invariant
709            // for Go would mean "fixing" behavior that was already deliberately chosen.
710            //
711            // NuGet used to be excluded here too (`req = "*"` read as NuGet's own
712            // floating-version "latest stable" syntax rather than this ladder's existence
713            // check), but #423 added a fallback rung to `pick_latest_matching`/
714            // `select_latest_matching` (`deps-nuget/src/registry.rs`) so a prerelease-only
715            // package now resolves under a bare wildcard too, matching every other
716            // ecosystem — no exception needed anymore.
717            if matches!(id, "go") {
718                continue;
719            }
720
721            let prerelease_subject =
722                ecosystem_registry.select_latest_matching(&prerelease_only_fixture(), &req);
723            assert!(
724                prerelease_subject.is_some(),
725                "{id}: a package whose only releases so far are prerelease must still \
726                 resolve under a wildcard requirement (#421)"
727            );
728        }
729    }
730}