Skip to main content

deps_engine/
setup.rs

1//! Ecosystem registration.
2//!
3//! [`EcosystemRuntime`] bundles the live-updatable settings every adapter threads into the
4//! ecosystems that need them, and [`register_ecosystems`] wires every feature-enabled
5//! `deps-<ecosystem>` crate into a [`deps_core::EcosystemRegistry`].
6//!
7//! Moved verbatim from `deps-lsp/src/lib.rs` (issue #1058) so `deps-cli`/`deps-mcp` share the
8//! exact same registration instead of each maintaining an independent, non-uniform copy — see
9//! `specs/062-cli-check-mode/architecture-decision.md` §5.2 for why per-adapter re-registration
10//! was rejected.
11
12use std::sync::Arc;
13use std::sync::atomic::AtomicBool;
14
15use deps_core::policy_config::PolicyConfig;
16use deps_core::{EcosystemRegistry, HttpCache};
17
18/// Live-updatable settings [`register_ecosystems`] threads into every ecosystem that needs them.
19///
20/// Bundled into one struct (issue #561, M3) rather than growing that function's arity again
21/// for each new cross-ecosystem live flag.
22#[non_exhaustive]
23#[derive(Debug, Clone)]
24pub struct EcosystemRuntime {
25    /// Gates every workspace-declared registry index host (spec #443,
26    /// `registries.workspace_registries`).
27    pub policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
28    /// `registries.nuget_user_profile_sources` (issue #561, FR-006) — whether a NuGet
29    /// user-profile-tier `NuGet.Config` source with no repo-declared counterpart becomes a
30    /// routing hop, not just a credential source. Default `false`.
31    pub nuget_user_profile_sources: Arc<AtomicBool>,
32    /// `registries.gitlab_instance_host` (issue #466, spec FR-005a/FR-011a) — the raw
33    /// configured GitLab instance host string, or `None` when unset. A feature-agnostic
34    /// `Arc<RwLock<Option<String>>>` (not a `deps-gitlab-ci` type) since this struct is
35    /// un-`cfg`'d — see `deps_gitlab_ci::host::GitlabInstanceHost`'s docs for why host
36    /// validation lives in that crate instead, applied on read.
37    pub gitlab_instance_host: Arc<std::sync::RwLock<Option<String>>>,
38    /// `composer.lock` memoization cache (#1212 impl-critic follow-up) `register_ecosystems`
39    /// hands `ComposerEcosystem::with_context` — unlike the three fields above, this has a
40    /// sensible default (a fresh, private cache), so it is not a [`Self::new`] parameter; set
41    /// explicitly via [`Self::with_lockfile_cache`] so an adapter with its own long-lived
42    /// cache (e.g. `deps-lsp`'s `ServerState::lockfile_cache`, also read by its own
43    /// in-use-version resolution) can share that exact instance instead of Composer
44    /// classification parsing the same `composer.lock` independently.
45    pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
46}
47
48impl EcosystemRuntime {
49    /// Constructs the runtime from its three live-updatable settings handles.
50    ///
51    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
52    /// this crate, so every other crate — including anything embedding [`register_ecosystems`],
53    /// this module's advertised entry point — must use this constructor instead. Unlike some
54    /// config-DTO builders elsewhere in this workspace (e.g. `deps_lsp::config::InlayHintsConfig::new`),
55    /// all three fields are required here: none has a sensible default, so there is no
56    /// accompanying `with_*` chain — a future field can still add one without breaking this
57    /// signature.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use deps_core::net_policy::RegistryAccessPolicy;
63    /// use deps_engine::setup::EcosystemRuntime;
64    /// use std::sync::atomic::AtomicBool;
65    /// use std::sync::{Arc, RwLock};
66    ///
67    /// let runtime = EcosystemRuntime::new(
68    ///     Arc::new(RegistryAccessPolicy::default()),
69    ///     Arc::new(AtomicBool::new(false)),
70    ///     Arc::new(RwLock::new(None)),
71    /// );
72    /// assert!(!runtime.nuget_user_profile_sources.load(std::sync::atomic::Ordering::Relaxed));
73    /// ```
74    #[must_use]
75    pub fn new(
76        policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
77        nuget_user_profile_sources: Arc<AtomicBool>,
78        gitlab_instance_host: Arc<std::sync::RwLock<Option<String>>>,
79    ) -> Self {
80        Self {
81            policy,
82            nuget_user_profile_sources,
83            gitlab_instance_host,
84            lockfile_cache: Arc::new(deps_core::lockfile::LockFileCache::new()),
85        }
86    }
87
88    /// Overrides [`Self::lockfile_cache`]'s default (a fresh, private cache) with an existing
89    /// instance — see that field's own doc for why an adapter with its own long-lived cache
90    /// wants to do this.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use deps_core::lockfile::LockFileCache;
96    /// use deps_core::policy_config::PolicyConfig;
97    /// use deps_engine::setup::EcosystemRuntime;
98    /// use std::sync::Arc;
99    ///
100    /// let shared = Arc::new(LockFileCache::new());
101    /// let runtime =
102    ///     EcosystemRuntime::from_policy(&PolicyConfig::default()).with_lockfile_cache(Arc::clone(&shared));
103    /// assert!(Arc::ptr_eq(&runtime.lockfile_cache, &shared));
104    /// ```
105    #[must_use]
106    pub fn with_lockfile_cache(
107        mut self,
108        lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
109    ) -> Self {
110        self.lockfile_cache = lockfile_cache;
111        self
112    }
113
114    /// Builds the runtime's three live-updatable handles from a [`PolicyConfig`] snapshot.
115    ///
116    /// Construction only — this does not touch any adapter-side state (e.g. `deps-lsp`'s
117    /// `ServerState::cache`/`cold_start_limiter`, or its
118    /// `warn_if_gitlab_instance_host_invalid` notification call): those remain the caller's
119    /// responsibility, since they carry their own ordering constraints relative to the
120    /// adapter's own config-reload lifecycle (issue #1058; see
121    /// `specs/062-cli-check-mode/architecture-decision.md` §8, PR 1b-ii).
122    ///
123    /// The three values themselves come from
124    /// [`RegistriesConfig::resolve`](deps_core::policy_config::RegistriesConfig::resolve),
125    /// shared with `deps-lsp`'s `initialize`/`did_change_configuration` config-reload sites so
126    /// this derivation exists in exactly one place (issue #1058, T009).
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use deps_core::policy_config::PolicyConfig;
132    /// use deps_engine::setup::EcosystemRuntime;
133    ///
134    /// let runtime = EcosystemRuntime::from_policy(&PolicyConfig::default());
135    /// assert!(!runtime.nuget_user_profile_sources.load(std::sync::atomic::Ordering::Relaxed));
136    /// ```
137    #[must_use]
138    pub fn from_policy(policy: &PolicyConfig) -> Self {
139        let resolved = policy.registries.resolve();
140        Self::new(
141            Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
142                resolved.workspace_registries,
143            )),
144            Arc::new(AtomicBool::new(resolved.nuget_user_profile_sources)),
145            Arc::new(std::sync::RwLock::new(resolved.gitlab_instance_host)),
146        )
147    }
148}
149
150/// Validates a `registries.gitlab_instance_host` value against the live registry-access
151/// policy, without the caller needing to name `deps_gitlab_ci` directly.
152///
153/// Exists so `deps-lsp` (and any future adapter) can validate this value while depending only
154/// on `deps-engine` — per `specs/062-cli-check-mode/architecture-decision.md` §3.3's placement
155/// rule, code that must name a concrete ecosystem type belongs in `deps-engine`, not in an
156/// adapter crate.
157///
158/// # Errors
159///
160/// Returns [`deps_core::net_policy::IndexUrlError`] under the same conditions as
161/// [`deps_gitlab_ci::GitlabHost::parse`] — `raw` contains a URL-structural character, fails to
162/// parse, is not `https`-eligible, carries userinfo, round-trips to a different host, or
163/// resolves to a [`deps_core::net_policy::HostClass`] the current policy blocks.
164///
165/// # Examples
166///
167/// ```
168/// use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
169/// use deps_engine::setup::validate_gitlab_instance_host;
170///
171/// let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly);
172/// assert!(validate_gitlab_instance_host("gitlab.com", &policy).is_ok());
173/// assert!(validate_gitlab_instance_host("gitlab.com/evil", &policy).is_err());
174/// ```
175#[cfg(feature = "gitlab-ci")]
176pub fn validate_gitlab_instance_host(
177    raw: &str,
178    policy: &deps_core::net_policy::RegistryAccessPolicy,
179) -> Result<(), deps_core::net_policy::IndexUrlError> {
180    deps_gitlab_ci::GitlabHost::parse(raw, policy).map(|_| ())
181}
182
183/// Declares an ecosystem: re-exports types and registers at runtime.
184///
185/// `$types` re-export rule (#834 §6 last bullet — the list used to be asymmetric with no
186/// stated rule: `*Formatter` re-exported for 8/14 ecosystems, `*LockParser` for 4/14,
187/// `*Registry` for 13/14): **always list every `*Registry`/`*Formatter`/`*LockParser`
188/// (or ecosystem-specific-named lock parser, e.g. `GoSumParser`) type the ecosystem crate
189/// actually defines and re-exports from its own crate root.** Omit only when the crate
190/// genuinely has none — e.g. `deps-gradle` has no `GradleRegistry` (reuses
191/// `deps_maven::MavenCentralRegistry` directly), and several ecosystems omit a
192/// `*LockParser` entry because the crate has no `lockfile` module at all (Maven, Gradle,
193/// GitHub Actions, GitLab CI — no lock file format exists for these; Deno — `deno.lock`
194/// exists as a format, but this crate has no parser for it yet, a real coverage gap rather
195/// than "no format" like the other four).
196macro_rules! ecosystem {
197    ($feature:literal, $crate_name:ident, $ecosystem:ident, [$($types:ident),* $(,)?]) => {
198        #[cfg(feature = $feature)]
199        pub use $crate_name::{$ecosystem, $($types),*};
200    };
201}
202
203/// Registers ecosystem if feature is enabled.
204macro_rules! register {
205    ($feature:literal, $ecosystem:ident, $registry:expr, $cache:expr) => {
206        #[cfg(feature = $feature)]
207        $registry.register(Arc::new($ecosystem::new(Arc::clone($cache))));
208    };
209}
210
211// =============================================================================
212// Ecosystems — to add new: 1) feature in Cargo.toml  2) add ecosystem!() + register!()
213// =============================================================================
214
215ecosystem!(
216    "cargo",
217    deps_cargo,
218    CargoEcosystem,
219    [
220        CargoDependency,
221        CargoDependencySection,
222        CargoFormatter,
223        CargoLockParser,
224        CargoParseResult,
225        CargoParser,
226        CargoRegistry,
227        CargoVersion,
228        CrateInfo,
229        CratesIoRegistry,
230        parse_cargo_toml,
231    ]
232);
233
234ecosystem!(
235    "npm",
236    deps_npm,
237    NpmEcosystem,
238    [
239        NpmDependency,
240        NpmDependencySection,
241        NpmFormatter,
242        NpmLockParser,
243        NpmPackage,
244        NpmParseResult,
245        NpmRegistry,
246        NpmVersion,
247        parse_package_json,
248    ]
249);
250
251ecosystem!(
252    "pypi",
253    deps_pypi,
254    PypiEcosystem,
255    [
256        PypiDependency,
257        PypiDependencySection,
258        PypiFormatter,
259        PypiLockParser,
260        PypiParser,
261        PypiRegistry,
262        PypiVersion,
263    ]
264);
265
266ecosystem!(
267    "go",
268    deps_go,
269    GoEcosystem,
270    [
271        GoDependency,
272        GoDirective,
273        GoFormatter,
274        GoParseResult,
275        GoRegistry,
276        GoSumParser,
277        GoVersion,
278        parse_go_mod,
279    ]
280);
281
282ecosystem!(
283    "bundler",
284    deps_bundler,
285    BundlerEcosystem,
286    [
287        BundlerDependency,
288        BundlerFormatter,
289        BundlerParseResult,
290        BundlerVersion,
291        DependencyGroup,
292        GemInfo,
293        GemfileLockParser,
294        RubyGemsRegistry,
295        parse_gemfile,
296    ]
297);
298
299ecosystem!(
300    "dart",
301    deps_dart,
302    DartEcosystem,
303    [
304        DartDependency,
305        DartParseResult,
306        DartVersion,
307        DartFormatter,
308        PackageInfo,
309        PubDevRegistry,
310        PubspecLockParser,
311        parse_pubspec_yaml,
312    ]
313);
314
315ecosystem!(
316    "maven",
317    deps_maven,
318    MavenEcosystem,
319    [
320        MavenDependency,
321        MavenParseResult,
322        MavenVersion,
323        MavenFormatter,
324        ArtifactInfo,
325        MavenCentralRegistry,
326        parse_pom_xml,
327    ]
328);
329
330ecosystem!(
331    "gradle",
332    deps_gradle,
333    GradleEcosystem,
334    [
335        GradleDependency,
336        GradleParseResult,
337        GradleVersion,
338        GradleFormatter,
339        parse_gradle,
340    ]
341);
342
343ecosystem!(
344    "swift",
345    deps_swift,
346    SwiftEcosystem,
347    [
348        SwiftDependency,
349        SwiftParseResult,
350        SwiftVersion,
351        SwiftPackage,
352        SwiftFormatter,
353        SwiftRegistry,
354        SwiftLockParser,
355        parse_package_swift,
356    ]
357);
358
359ecosystem!(
360    "composer",
361    deps_composer,
362    ComposerEcosystem,
363    [
364        ComposerDependency,
365        ComposerFormatter,
366        ComposerLockParser,
367        ComposerSection,
368        ComposerPackage,
369        ComposerParseResult,
370        PackagistRegistry,
371        ComposerVersion,
372        parse_composer_json,
373    ]
374);
375
376// Note: `PackageInfo` is deliberately omitted from this re-export list — it collides with
377// `deps_dart::PackageInfo`, already re-exported above. Reachable directly as
378// `deps_nuget::PackageInfo` for anything that needs it.
379ecosystem!(
380    "nuget",
381    deps_nuget,
382    NuGetEcosystem,
383    [
384        NuGetDependency,
385        NuGetParseResult,
386        NuGetVersion,
387        NuGetFormatter,
388        NuGetRegistry,
389        NuGetLockParser,
390        parse_project_file,
391    ]
392);
393
394ecosystem!(
395    "deno",
396    deps_deno,
397    DenoEcosystem,
398    [
399        DenoDependency,
400        DenoDependencySection,
401        DenoFormatter,
402        DenoMetadata,
403        DenoParseResult,
404        DenoRegistry,
405        JsrPackage,
406        JsrRegistry,
407        JsrVersion,
408        parse_deno_json,
409    ]
410);
411
412ecosystem!(
413    "github-actions",
414    deps_github_actions,
415    GithubActionsEcosystem,
416    [
417        GithubActionsDependency,
418        GithubActionsFormatter,
419        GithubActionsParseResult,
420        GithubActionsRegistry,
421        GithubActionsVersion,
422        parse_workflow_yaml,
423    ]
424);
425
426ecosystem!(
427    "gitlab-ci",
428    deps_gitlab_ci,
429    GitlabCiEcosystem,
430    [
431        GitlabCiDependency,
432        GitlabCiFormatter,
433        GitlabCiParseResult,
434        GitlabCiRegistry,
435        GitlabCiVersion,
436        parse_gitlab_ci_yaml,
437    ]
438);
439
440/// Registers all enabled ecosystems.
441///
442/// `cargo` is special-cased (spec #443/#441, plan-1b §1.6): unlike `register!`'s generic
443/// `Ecosystem::new(cache)` call, `CargoEcosystem` needs `policy` threaded through
444/// `CargoEcosystem::with_context` so the calling adapter's live-updatable
445/// `Arc<RegistryAccessPolicy>` (e.g. `deps-lsp`'s `document::state::ServerState::registry_policy`)
446/// is the exact same handle every Cargo parse reads — the adapter updating it then takes
447/// effect immediately, with no need to reconstruct the ecosystem.
448///
449/// `npm` and `deno` are special-cased (#312): when both features are enabled, they share
450/// one `NpmRegistry` instance — built once here and handed to both `NpmEcosystem` and
451/// `DenoEcosystem`'s `npm:`-scheme half via `with_registry`/`with_npm` — instead of each
452/// constructing its own. `NpmRegistry` is cheaply `Clone` (its `HttpCache` and
453/// freshness-path publish-time map are both `Arc`-wrapped internally), so this dedupes the
454/// freshness path's full-packument fetch and its publish-time cache for a package
455/// appearing in both `package.json` and a `deno.json` `npm:`-specifier dependency, on top
456/// of the plain cached GETs the shared `cache` already dedupes.
457/// Returns every ecosystem id this call threaded the live `RegistryAccessPolicy` handle
458/// into (issue #592 security M1) — the single source of truth `deps_lsp::config::reparse_scope`'s
459/// caller consults to scope a `registries.workspace_registries` reparse, so that set can
460/// never drift from what this function actually wires up. Adding a 6th policy-consuming
461/// ecosystem means editing this function anyway (to thread `policy` through its parse
462/// context); pushing its id onto the returned list at that same call site keeps the two
463/// facts — "receives the policy" and "is in the reparse scope" — physically inseparable,
464/// rather than duplicated across two independently-editable places.
465///
466/// # Examples
467///
468/// ```
469/// use deps_core::policy_config::PolicyConfig;
470/// use deps_core::{EcosystemRegistry, HttpCache};
471/// use deps_engine::setup::{EcosystemRuntime, register_ecosystems};
472/// use std::sync::Arc;
473///
474/// let registry = EcosystemRegistry::new();
475/// let cache = Arc::new(HttpCache::new());
476/// let runtime = EcosystemRuntime::from_policy(&PolicyConfig::default());
477/// let workspace_registry_ecosystems = register_ecosystems(&registry, cache, &runtime);
478///
479/// // Every id this call reports as policy-consuming is actually registered.
480/// for id in &workspace_registry_ecosystems {
481///     assert!(registry.get(*id).is_some());
482/// }
483/// ```
484pub fn register_ecosystems(
485    registry: &EcosystemRegistry,
486    cache: Arc<HttpCache>,
487    runtime: &EcosystemRuntime,
488) -> Vec<deps_core::EcosystemId> {
489    let policy = Arc::clone(&runtime.policy);
490    // Keeps `policy` used even when none of its consumers (cargo, npm, pypi, go, nuget,
491    // gitlab-ci) are compiled in.
492    let _ = &policy;
493    // Keeps `registry`/`cache` used even when every ecosystem feature is compiled out.
494    let _ = (&registry, &cache);
495    let mut workspace_registry_ecosystems = Vec::new();
496    // Keeps `mut` used even when none of the five features that push into this vec below
497    // (cargo, npm, pypi, go, nuget) are enabled.
498    let _ = &mut workspace_registry_ecosystems;
499
500    #[cfg(feature = "cargo")]
501    {
502        let context = deps_cargo::parser::CargoParseContext::new(
503            Arc::clone(&policy),
504            Arc::new(deps_cargo::config::ConfigFileCache::new()),
505        );
506        registry.register(Arc::new(CargoEcosystem::with_context(
507            Arc::clone(&cache),
508            context,
509        )));
510        workspace_registry_ecosystems.push(deps_core::EcosystemId::Cargo);
511    }
512
513    #[cfg(all(feature = "npm", feature = "deno"))]
514    {
515        // Shared with `deno_context` below (#1212) so a `.npmrc` file ancestor-walked once
516        // for this workspace is cached and reused across both ecosystems, not re-read from
517        // disk independently per manifest kind.
518        let npm_config_cache = Arc::new(deps_npm::config::NpmConfigCache::new());
519        let npm_context = deps_npm::config::NpmParseContext::new(
520            Arc::clone(&policy),
521            Arc::clone(&npm_config_cache),
522            Arc::new(deps_npm::catalog::PnpmWorkspaceCache::new()),
523        );
524        let deno_context =
525            deps_deno::parser::DenoParseContext::new(Arc::clone(&policy), npm_config_cache);
526        let npm_registry = Arc::new(NpmRegistry::new(Arc::clone(&cache)));
527        registry.register(Arc::new(NpmEcosystem::with_context(
528            Arc::clone(&npm_registry),
529            npm_context,
530        )));
531        workspace_registry_ecosystems.push(deps_core::EcosystemId::Npm);
532        // `DenoEcosystem::with_context` shares the registry, policy, and `.npmrc` cache above.
533        // "deno" joins `workspace_registry_ecosystems` too (#1212 S4 impl-critic fix): since
534        // `DenoParseContext.policy` now flows into real classification (`npm:`-scope
535        // resolution), a live `registries.workspace_registries` update must reach an
536        // already-open `deno.json` the same way it reaches `package.json` — omitting it here
537        // would reproduce the #592 stale-classification failure pattern
538        // `deps_lsp::config::reparse_scope`'s own doc warns against.
539        registry.register(Arc::new(DenoEcosystem::with_context(
540            Arc::clone(&cache),
541            npm_registry.as_ref().clone(),
542            deno_context,
543        )));
544        workspace_registry_ecosystems.push(deps_core::EcosystemId::Deno);
545    }
546    // npm is explicit, not via `register!` (spec 032, S3): the macro's default
547    // `NpmParseContext` would never see a live `initialize`/`didChangeConfiguration` update.
548    #[cfg(all(feature = "npm", not(feature = "deno")))]
549    {
550        let npm_context = deps_npm::config::NpmParseContext::new(
551            Arc::clone(&policy),
552            Arc::new(deps_npm::config::NpmConfigCache::new()),
553            Arc::new(deps_npm::catalog::PnpmWorkspaceCache::new()),
554        );
555        registry.register(Arc::new(NpmEcosystem::with_context(
556            Arc::new(NpmRegistry::new(Arc::clone(&cache))),
557            npm_context,
558        )));
559        workspace_registry_ecosystems.push(deps_core::EcosystemId::Npm);
560    }
561    // deno-without-npm is explicit too, not via `register!` (#1212 S5 impl-critic fix): the
562    // macro's default `DenoParseContext` would never see a live
563    // `initialize`/`did_change_configuration` policy update either, even with no `NpmEcosystem`
564    // present to share a `.npmrc` cache instance with. Uses `DenoParseContext::with_policy` and
565    // `DenoEcosystem::with_context_standalone` (impl-critic #1 follow-up), not `::new`/
566    // `with_context`, because this arm compiles with `deps-engine`'s own `deps-npm` dependency
567    // absent (it is optional, gated behind `deps-engine`'s *own* `npm` feature, which is off
568    // here) — neither `deps_npm::config::NpmConfigCache` nor `NpmRegistry` is nameable from
569    // this crate in this build configuration at all; both helpers build what they need
570    // internally inside `deps-deno`, which always depends on `deps-npm` unconditionally.
571    #[cfg(all(feature = "deno", not(feature = "npm")))]
572    {
573        let deno_context = deps_deno::parser::DenoParseContext::with_policy(Arc::clone(&policy));
574        registry.register(Arc::new(DenoEcosystem::with_context_standalone(
575            Arc::clone(&cache),
576            deno_context,
577        )));
578        workspace_registry_ecosystems.push(deps_core::EcosystemId::Deno);
579    }
580
581    // pypi is explicit, not via `register!` (spec 033, mirrors npm's spec 032 S3): the
582    // macro's default `RegistryAccessPolicy` would never see a live config update.
583    #[cfg(feature = "pypi")]
584    {
585        registry.register(Arc::new(PypiEcosystem::with_policy(
586            Arc::new(PypiRegistry::new(Arc::clone(&cache))),
587            Arc::clone(&policy),
588        )));
589        workspace_registry_ecosystems.push(deps_core::EcosystemId::Pypi);
590    }
591
592    // go is explicit, not via `register!` (spec 034, mirrors npm's spec 032 S3): the macro's
593    // default `GoParseContext` would never see a live `$GOENV` policy update.
594    #[cfg(feature = "go")]
595    {
596        let go_context = deps_go::config::GoParseContext::new(
597            Arc::clone(&policy),
598            Arc::new(deps_go::config::GoEnvCache::new()),
599            deps_go::config::goenv_path(),
600        );
601        registry.register(Arc::new(GoEcosystem::with_context(
602            Arc::new(GoRegistry::new(Arc::clone(&cache))),
603            go_context,
604        )));
605        workspace_registry_ecosystems.push(deps_core::EcosystemId::Go);
606    }
607    register!("bundler", BundlerEcosystem, registry, &cache);
608    register!("dart", DartEcosystem, registry, &cache);
609    register!("maven", MavenEcosystem, registry, &cache);
610    register!("gradle", GradleEcosystem, registry, &cache);
611    register!("swift", SwiftEcosystem, registry, &cache);
612
613    // composer is explicit, not via `register!` (#1212 impl-critic follow-up): shares
614    // `runtime.lockfile_cache` with whatever else in this process reads `composer.lock` by the
615    // same mtime-keyed cache instance, instead of parsing it independently on every reparse.
616    #[cfg(feature = "composer")]
617    registry.register(Arc::new(ComposerEcosystem::with_context(
618        Arc::clone(&cache),
619        Arc::clone(&runtime.lockfile_cache),
620    )));
621
622    // nuget is explicit, not via `register!` (#523, mirrors npm's/pypi's precedent): the
623    // macro's default `RegistryAccessPolicy` would never see a live config update.
624    #[cfg(feature = "nuget")]
625    {
626        let nuget_context = deps_nuget::config::NuGetParseContext::new(
627            Arc::clone(&policy),
628            Arc::new(deps_nuget::config::NuGetConfigCache::new()),
629            Arc::clone(&runtime.nuget_user_profile_sources),
630        );
631        registry.register(Arc::new(NuGetEcosystem::with_context(
632            Arc::new(NuGetRegistry::new(Arc::clone(&cache))),
633            nuget_context,
634        )));
635        workspace_registry_ecosystems.push(deps_core::EcosystemId::NuGet);
636    }
637
638    register!("github-actions", GithubActionsEcosystem, registry, &cache);
639
640    // gitlab-ci is explicit, not via `register!` (#466, mirrors github-actions'/nuget's
641    // precedent): the macro's default config would never see a live self-hosted-instance
642    // or single-token-host update (spec FR-005a/FR-011a).
643    #[cfg(feature = "gitlab-ci")]
644    registry.register(Arc::new(GitlabCiEcosystem::with_context(
645        Arc::clone(&cache),
646        Arc::clone(&policy),
647        Arc::clone(&runtime.gitlab_instance_host),
648    )));
649
650    workspace_registry_ecosystems
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    fn test_runtime() -> EcosystemRuntime {
658        EcosystemRuntime::new(
659            Arc::new(deps_core::net_policy::RegistryAccessPolicy::default()),
660            Arc::new(AtomicBool::new(false)),
661            Arc::new(std::sync::RwLock::new(None)),
662        )
663    }
664
665    /// The doctest on [`EcosystemRuntime::from_policy`] only exercises `PolicyConfig::default()`
666    /// (empty `gitlab_instance_host` -> `None`, `nuget_user_profile_sources` -> `false`). This
667    /// covers its other branches: a non-empty `gitlab_instance_host`, `nuget_user_profile_sources
668    /// = true`, and a non-default `workspace_registries` setting.
669    #[test]
670    fn test_from_policy_non_default_branches() {
671        use deps_core::net_policy::WorkspaceRegistryAccess;
672        use deps_core::policy_config::{
673            PolicyConfig, RegistriesConfig, WorkspaceRegistriesSetting,
674        };
675
676        let policy = PolicyConfig {
677            registries: RegistriesConfig::new()
678                .with_workspace_registries(WorkspaceRegistriesSetting::All)
679                .with_nuget_user_profile_sources(true)
680                .with_gitlab_instance_host("gitlab.corp"),
681            ..PolicyConfig::default()
682        };
683
684        let runtime = EcosystemRuntime::from_policy(&policy);
685
686        assert_eq!(runtime.policy.get(), WorkspaceRegistryAccess::All);
687        assert!(
688            runtime
689                .nuget_user_profile_sources
690                .load(std::sync::atomic::Ordering::Relaxed)
691        );
692        assert_eq!(
693            runtime
694                .gitlab_instance_host
695                .read()
696                .unwrap_or_else(std::sync::PoisonError::into_inner)
697                .as_deref(),
698            Some("gitlab.corp")
699        );
700    }
701
702    /// Direct `cargo nextest` coverage for [`validate_gitlab_instance_host`] — its doctest
703    /// exercises the same two cases inline, but only as a doctest (tester finding, issue
704    /// #1073). Also checks the rejected-host error never leaks the raw credential, mirroring
705    /// `deps-lsp`'s `test_gitlab_instance_host_invalid_message_redacts_credential`, since
706    /// [`deps_core::net_policy::IndexUrlError::InvalidUrl`] wraps an already-redacted
707    /// [`deps_core::net_policy::RedactedUrl`].
708    #[cfg(feature = "gitlab-ci")]
709    #[test]
710    fn test_validate_gitlab_instance_host_accepts_valid_rejects_invalid() {
711        use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
712
713        let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly);
714
715        assert!(validate_gitlab_instance_host("gitlab.com", &policy).is_ok());
716
717        let raw = "user:hunter2@gitlab.corp";
718        let error = validate_gitlab_instance_host(raw, &policy)
719            .expect_err("credential-shaped host must be rejected");
720        assert!(
721            !error.to_string().contains("hunter2"),
722            "rejected-host error must not leak the raw credential: {error}"
723        );
724    }
725
726    /// Smoke test: `register_ecosystems` must not panic under any feature combination.
727    /// Per-ecosystem "is it actually registered" coverage moved to
728    /// [`test_ecosystem_id_all_registered`] (#758) — driven by
729    /// [`deps_core::EcosystemId::ALL`] instead of this hand-written, drift-prone 14-line list.
730    #[test]
731    fn test_register_ecosystems() {
732        let registry = Arc::new(EcosystemRegistry::new());
733        let cache = Arc::new(HttpCache::new());
734        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
735    }
736
737    /// Issue #592 security M1: every id `register_ecosystems` returns must actually be a
738    /// registered ecosystem (catches a typo'd `push` literal) and, for this feature set,
739    /// must exactly match the five ecosystems known to thread `RegistryAccessPolicy` through
740    /// their parse context — a regression here means either a policy-consuming ecosystem
741    /// was added without pushing its id (fails closed for `deps_lsp::config::reparse_scope`), or
742    /// an id was pushed for an ecosystem that no longer receives the policy (harmless
743    /// over-scoping, but signals the two facts drifted anyway).
744    #[test]
745    #[allow(
746        clippy::vec_init_then_push,
747        reason = "each push is independently feature-gated, so a `vec![]` literal can't \
748                  express the feature-conditional membership"
749    )]
750    fn test_register_ecosystems_workspace_registry_list_matches_registered_ecosystems() {
751        let registry = Arc::new(EcosystemRegistry::new());
752        let cache = Arc::new(HttpCache::new());
753        let workspace_registry_ecosystems =
754            register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
755
756        for id in &workspace_registry_ecosystems {
757            assert!(
758                registry.get(*id).is_some(),
759                "{id:?} was returned as policy-consuming but is not a registered ecosystem"
760            );
761        }
762
763        let mut expected: Vec<&str> = Vec::new();
764        #[cfg(feature = "cargo")]
765        expected.push("cargo");
766        #[cfg(feature = "npm")]
767        expected.push("npm");
768        #[cfg(feature = "deno")]
769        expected.push("deno");
770        #[cfg(feature = "pypi")]
771        expected.push("pypi");
772        #[cfg(feature = "go")]
773        expected.push("go");
774        #[cfg(feature = "nuget")]
775        expected.push("nuget");
776        expected.sort_unstable();
777        let mut actual: Vec<&str> = workspace_registry_ecosystems
778            .iter()
779            .map(|id| id.id())
780            .collect();
781        actual.sort_unstable();
782        assert_eq!(
783            actual, expected,
784            "workspace-registry-policy ecosystem set changed — update this test's `expected` \
785             list alongside whatever registration change caused it"
786        );
787    }
788
789    /// Layer 1a (#758): completeness — every [`deps_core::EcosystemId::ALL`] variant must
790    /// actually be registered by [`register_ecosystems`]. Driven by `ALL` itself, not
791    /// `registry.ecosystem_ids()`, so an ecosystem declared in the enum but never wired in
792    /// fails this test instead of silently vanishing from coverage.
793    ///
794    /// Gated on every ecosystem feature at once: `ALL` always lists 14 variants regardless of
795    /// which features are enabled for this build, so this specific claim — "all 14 are
796    /// present" — is only meaningful, and only makes sense to check, in an all-features build.
797    /// The per-ecosystem invariants that don't depend on all 14 being present live in the
798    /// ungated [`test_registered_ecosystems_universal_invariants`] instead (#758 impl-critic
799    /// S2): unlike this completeness check, those must keep working under any feature subset,
800    /// the way the two hand-written per-feature lists this pair replaces used to.
801    #[cfg(all(
802        feature = "cargo",
803        feature = "npm",
804        feature = "pypi",
805        feature = "go",
806        feature = "bundler",
807        feature = "dart",
808        feature = "maven",
809        feature = "gradle",
810        feature = "swift",
811        feature = "composer",
812        feature = "nuget",
813        feature = "deno",
814        feature = "github-actions",
815        feature = "gitlab-ci"
816    ))]
817    #[test]
818    fn test_ecosystem_id_all_registered() {
819        let registry = Arc::new(EcosystemRegistry::new());
820        let cache = Arc::new(HttpCache::new());
821        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
822
823        for id in deps_core::EcosystemId::ALL {
824            assert!(
825                registry.get(*id).is_some(),
826                "{id:?} is in EcosystemId::ALL but was not registered by register_ecosystems"
827            );
828        }
829    }
830
831    /// Layer 1b (#758): universal, offline invariants every ecosystem this build actually
832    /// registers must satisfy. Deliberately **ungated** — unlike
833    /// [`test_ecosystem_id_all_registered`]'s completeness claim, none of these invariants
834    /// depend on all 14 ecosystems being present, so this iterates whatever
835    /// `registry.ecosystem_ids()` this build's feature set produced (#758 impl-critic S2):
836    /// under `--no-default-features --features npm`, it still covers `npm` alone; under the
837    /// default (all 14) build, it covers all 14, same as before the split.
838    ///
839    /// Per ecosystem: id round-trip; `display_name()` non-empty and unique across the set; at
840    /// least one routing surface non-empty; `lockfile_filenames().is_empty() ==
841    /// lockfile_provider().is_none()`; `package_url` hostile-input safety (display sink only —
842    /// see `deps_core::conformance`'s doc for the display-vs-fetch sink split; does **not**
843    /// prove the URL is non-degenerate, that is `formatter_conformance!`'s job per ecosystem);
844    /// every [`deps_core::conformance::GENERIC_TEMPLATE_PLACEHOLDERS`] form is guarded (#1391);
845    /// `completion_insert_text` does not panic.
846    #[test]
847    fn test_registered_ecosystems_universal_invariants() {
848        let registry = Arc::new(EcosystemRegistry::new());
849        let cache = Arc::new(HttpCache::new());
850        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
851
852        let mut display_names = std::collections::HashSet::new();
853
854        for id in registry.ecosystem_ids() {
855            let ecosystem = registry
856                .get(id)
857                .unwrap_or_else(|| panic!("{id:?} came from registry.ecosystem_ids() itself"));
858
859            // The only remaining guarantee that `Ecosystem::id()` (still used by tracing
860            // spans/logs) agrees with routing, now that routing itself keys on
861            // `Ecosystem::ecosystem_id()` instead (see `EcosystemRegistry::register`).
862            assert_eq!(
863                ecosystem.ecosystem_id(),
864                id,
865                "{id:?}: Ecosystem::ecosystem_id() disagrees with the registry key"
866            );
867            assert_eq!(ecosystem.id(), id.id(), "{id:?}: Ecosystem::id() mismatch");
868
869            let display_name = ecosystem.display_name();
870            assert!(!display_name.is_empty(), "{id:?} has an empty display_name");
871            assert!(
872                display_names.insert(display_name),
873                "{id:?}'s display_name {display_name:?} collides with another ecosystem's"
874            );
875
876            assert!(
877                !ecosystem.manifest_filenames().is_empty()
878                    || !ecosystem.manifest_patterns().is_empty()
879                    || !ecosystem.manifest_extensions().is_empty()
880                    || !ecosystem.manifest_directory_patterns().is_empty(),
881                "{id:?} has no routing surface at all (manifest_filenames/patterns/extensions/directory_patterns)"
882            );
883
884            assert_eq!(
885                ecosystem.lockfile_filenames().is_empty(),
886                ecosystem.lockfile_provider().is_none(),
887                "{id:?}: lockfile_filenames()/lockfile_provider() disagree on whether a lock file format exists"
888            );
889
890            // Hostile-input safety for the `package_url` display sink (#758 security-review),
891            // shared with `formatter_conformance!`'s per-crate check (#782 gap 1) — see
892            // `assert_package_url_hostile_input_safe`'s doc for the full rationale.
893            deps_core::conformance::assert_package_url_hostile_input_safe(
894                ecosystem.formatter(),
895                &format!("{id:?}"),
896            );
897
898            // Generic-template-placeholder guard (#1391), shared with `formatter_conformance!`'s
899            // per-crate check — covers every *registered* ecosystem here, including a future
900            // 15th one, with no per-crate wiring needed.
901            deps_core::conformance::assert_generic_template_placeholders_guarded(
902                ecosystem.formatter(),
903                &format!("{id:?}"),
904            );
905
906            let metadata = deps_core::test_util::MockMetadata::new("conformance-probe", "1.0.0");
907            let _ = ecosystem.completion_insert_text(&metadata);
908        }
909    }
910
911    /// #1652: every *registered* ecosystem's formatter answers an oversized requirement with the
912    /// gate's unmodellable results (no matcher, up to date, not already resolved, `Unresolved`).
913    #[test]
914    fn test_oversized_requirement_semantics_hold_for_every_ecosystem() {
915        use deps_core::lsp_helpers::{MAX_REQUIREMENT_LEN, RequirementGate, RequirementStatus};
916        use deps_core::{ConcreteVersion, VersionReq};
917
918        let registry = Arc::new(EcosystemRegistry::new());
919        let cache = Arc::new(HttpCache::new());
920        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
921
922        let oversized = VersionReq::new("1".repeat(MAX_REQUIREMENT_LEN + 1));
923        let latest = ConcreteVersion::from("1.0.0");
924
925        for id in registry.ecosystem_ids() {
926            let ecosystem = registry
927                .get(id)
928                .unwrap_or_else(|| panic!("{id:?} came from the registry's own ids"));
929            let formatter = ecosystem.formatter();
930
931            assert!(
932                formatter.compile_requirement(&oversized).is_none(),
933                "{id:?}: compile_requirement must not compile an oversized requirement"
934            );
935            assert!(
936                formatter.is_requirement_up_to_date(&oversized, &latest),
937                "{id:?}: is_requirement_up_to_date must treat an oversized requirement as \
938                 suppressed (true, not outdated)"
939            );
940            assert!(
941                !formatter.requirement_already_resolves_to(&oversized, &latest),
942                "{id:?}: requirement_already_resolves_to must fail closed (false) for an \
943                 oversized requirement"
944            );
945            assert_eq!(
946                formatter.requirement_status(&oversized, &latest),
947                RequirementStatus::Unresolved,
948                "{id:?}: an oversized requirement must be Unresolved"
949            );
950        }
951    }
952
953    /// CRITICAL regression (issue #706 review): GitHub Actions' `action.yml`/`action.yaml`
954    /// bare-basename routing and GitLab CI's `.gitlab/ci/*.yml` directory-pattern routing
955    /// can both match `.gitlab/ci/action.yml` — before `EcosystemRegistry::for_uri`'s
956    /// fix (deps-core), the basename match was checked first and always won, silently
957    /// routing a real GitLab CI file to `github-actions` (which would then, on top of
958    /// that, degrade it to zero dependencies since it lacks a top-level `runs:` key —
959    /// total, silent loss of hover/diagnostics/completions for the file). Exercises the
960    /// real production registry both real ecosystem crates are wired into, not a mock.
961    #[cfg(all(feature = "github-actions", feature = "gitlab-ci"))]
962    #[test]
963    fn test_gitlab_ci_directory_pattern_wins_over_github_actions_basename_match() {
964        let registry = Arc::new(EcosystemRegistry::new());
965        let cache = Arc::new(HttpCache::new());
966        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
967
968        let uri = deps_core::test_util::test_uri("/repo/.gitlab/ci/action.yml");
969        assert_eq!(
970            registry.for_uri(&uri).map(|e| e.id()),
971            Some(deps_core::EcosystemId::GitlabCi.id()),
972            "a real .gitlab/ci/action.yml file must route to gitlab-ci, not github-actions"
973        );
974
975        // Non-conflicting action.yml locations must be unaffected.
976        let root_action = deps_core::test_util::test_uri("/repo/action.yml");
977        assert_eq!(
978            registry.for_uri(&root_action).map(|e| e.id()),
979            Some(deps_core::EcosystemId::GithubActions.id())
980        );
981        let nested_action =
982            deps_core::test_util::test_uri("/repo/.github/actions/my-action/action.yml");
983        assert_eq!(
984            registry.for_uri(&nested_action).map(|e| e.id()),
985            Some(deps_core::EcosystemId::GithubActions.id())
986        );
987    }
988
989    /// Whether `formatter`'s own comparator (preferring
990    /// [`deps_core::lsp_helpers::RequirementResolution::compile_bounded_requirement`], falling back to
991    /// [`deps_core::lsp_helpers::RequirementResolution::version_satisfies_bounded_requirement`] when it
992    /// declines to compile) treats a bare `requirement` as an exact pin: it must match
993    /// `requirement` itself but reject both a higher patch (`"1.2.9"`) and a higher
994    /// minor/major (`"9.9.9"`) — the same "matches only this one version" shape
995    /// [`deps_core::lsp_helpers::concrete_pin_version`] asserts. Also correctly says `false`
996    /// for a genuine partial-version range (e.g. `"1.2"`), since that legitimately matches
997    /// more than one candidate.
998    #[cfg(any(
999        feature = "cargo",
1000        feature = "npm",
1001        feature = "go",
1002        feature = "bundler",
1003        feature = "dart",
1004        feature = "maven",
1005        feature = "composer",
1006        feature = "gradle",
1007        feature = "nuget",
1008        feature = "deno",
1009        feature = "github-actions",
1010        feature = "gitlab-ci",
1011        feature = "swift",
1012        feature = "pypi"
1013    ))]
1014    fn formatter_treats_bare_version_as_exact_pin(
1015        formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
1016        bare: &str,
1017    ) -> bool {
1018        use deps_core::lsp_helpers::RequirementGate;
1019        use deps_core::{ConcreteVersion, VersionReq};
1020
1021        let requirement = VersionReq::new(bare);
1022        let matches = |candidate: &str| -> bool {
1023            let version = ConcreteVersion::from(candidate);
1024            if let Some(matcher) = formatter.compile_requirement(&requirement) {
1025                matcher.matches(&version) == Some(true)
1026            } else {
1027                formatter.version_satisfies_requirement(&version, &VersionReq::new(bare))
1028            }
1029        };
1030
1031        matches(bare) && !matches("9.9.9") && !matches("1.2.9")
1032    }
1033
1034    /// How a given ecosystem's bare-version-is-a-pin verdict relates to its own formatter's
1035    /// comparator — see [`bare_version_agreement_expectation`].
1036    #[cfg(any(
1037        feature = "cargo",
1038        feature = "npm",
1039        feature = "go",
1040        feature = "bundler",
1041        feature = "dart",
1042        feature = "maven",
1043        feature = "composer",
1044        feature = "gradle",
1045        feature = "nuget",
1046        feature = "deno",
1047        feature = "github-actions",
1048        feature = "gitlab-ci",
1049        feature = "swift",
1050        feature = "pypi"
1051    ))]
1052    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1053    enum BareVersionAgreementExpectation {
1054        /// `deps-core`'s `concrete_pin_version` and the ecosystem's own comparator must
1055        /// agree on whether a bare full version (`"1.2.3"`) is an exact pin. When `true`,
1056        /// also checked against a bare *partial* version (`"1.2"`, impl-critic M1) —
1057        /// gated per-ecosystem because a partial-version *requirement* is not a concept
1058        /// every `Concrete`-policy ecosystem actually has: Go's own comparator, for
1059        /// instance, treats a bare string as a version *prefix* to support pseudo-version
1060        /// and `+incompatible`-suffix matching (`go_version_matches`), which makes it
1061        /// (correctly, for its real purpose) accept `"1.2"` against a candidate `"1.2.9"`
1062        /// — a false "divergence" against `deps-core` if compared as though `"1.2"` were
1063        /// a genuine partial-version requirement, which go.mod's `require` directive
1064        /// never actually contains (it is always a complete version). Only the
1065        /// ecosystems with a real bare-partial-is-a-range grammar (`AlwaysRange`'s Cargo
1066        /// caret, `ConcreteIfFullVersion`'s X-range/moving-tag ecosystems) get `true`.
1067        Checked { test_partial: bool },
1068        /// The ecosystem's own comparator would disagree with `deps-core`'s verdict, but
1069        /// the parser can never actually emit a bare requirement in the first place, so the
1070        /// divergence never reaches `concrete_pin_version` in practice (Swift, PyPI).
1071        LatentOnly,
1072        /// `deps-core` deliberately reports a bare requirement as a pin even though the
1073        /// ecosystem's own comparator disagrees — a documented approximation, not an
1074        /// oversight (NuGet).
1075        DeliberateApproximation,
1076    }
1077
1078    /// #669 regression guard: `deps-core`'s `bare_requirement_policy` hand-maintains a
1079    /// per-ecosystem model of "is a bare version requirement a pin or a range", but every
1080    /// ecosystem's own formatter already has the authoritative answer via
1081    /// `compile_requirement`/`version_satisfies_requirement`, and nothing kept the two in
1082    /// sync — this already caused two shipped bugs (#664 npm/Composer, #667 Deno). For every
1083    /// ecosystem this crate can register, classifies it via [`BareVersionAgreementExpectation`]
1084    /// and checks the matching invariant: `Checked` ecosystems must agree on both a bare full
1085    /// version (`"1.2.3"`) and a bare partial version (`"1.2"`, impl-critic M1); `LatentOnly`
1086    /// and `DeliberateApproximation` ecosystems must instead still exhibit the disagreement
1087    /// their exemption relies on (impl-critic M2) — so an alignment on either side (a parser
1088    /// change, a comparator change) fails this test instead of silently going stale.
1089    ///
1090    /// The `match` in [`bare_version_agreement_expectation`] is deliberately exhaustive
1091    /// (`.claude/CLAUDE.md`'s bug-class-#118 rule): a future 15th ecosystem must get an
1092    /// explicit arm — added to `Checked` or listed as a commented, reviewed exemption —
1093    /// rather than silently falling through a wildcard.
1094    #[cfg(any(
1095        feature = "cargo",
1096        feature = "npm",
1097        feature = "go",
1098        feature = "bundler",
1099        feature = "dart",
1100        feature = "maven",
1101        feature = "composer",
1102        feature = "gradle",
1103        feature = "nuget",
1104        feature = "deno",
1105        feature = "github-actions",
1106        feature = "gitlab-ci",
1107        feature = "swift",
1108        feature = "pypi"
1109    ))]
1110    fn bare_version_agreement_expectation(
1111        id: deps_core::EcosystemId,
1112    ) -> BareVersionAgreementExpectation {
1113        use BareVersionAgreementExpectation::{Checked, DeliberateApproximation, LatentOnly};
1114
1115        match id {
1116            // Cargo and the `ConcreteIfFullVersion` ecosystems: a bare partial version is a
1117            // real, distinct requirement shape from a bare full version, so both are checked.
1118            deps_core::EcosystemId::Cargo
1119            | deps_core::EcosystemId::Npm
1120            | deps_core::EcosystemId::Composer
1121            | deps_core::EcosystemId::Deno
1122            | deps_core::EcosystemId::GithubActions
1123            | deps_core::EcosystemId::GitlabCi => Checked { test_partial: true },
1124            // Go/Bundler/Dart/Maven/Gradle: no partial-version requirement concept exists (a
1125            // bare version is always complete), so only the full-version case is checked. Go's
1126            // comparator specifically treats a bare string as a version prefix, not a
1127            // partial-range, so testing `"1.2"` there would produce a false divergence.
1128            deps_core::EcosystemId::Go
1129            | deps_core::EcosystemId::Bundler
1130            | deps_core::EcosystemId::Dart
1131            | deps_core::EcosystemId::Maven
1132            | deps_core::EcosystemId::Gradle => Checked {
1133                test_partial: false,
1134            },
1135            // Swift: `compile_requirement`'s `semver::VersionReq` has the same bare-string
1136            // caret-range divergence as NuGet, but `deps-swift`'s parser always emits an
1137            // explicit range or exact pin, never a bare `"X.Y.Z"` — can't fire today.
1138            // Re-review if the parser ever changes to emit a bare form.
1139            deps_core::EcosystemId::Swift => LatentOnly,
1140            // PyPI: the parser retains the pep440 comparator on every requirement (an exact
1141            // pin parses to `"==1.2.3"`, never bare), so a bare requirement never reaches
1142            // this check. Re-review if the parser ever changes to emit a bare form.
1143            deps_core::EcosystemId::Pypi => LatentOnly,
1144            // NuGet (#669): a bare `Version="X"` is really an unbounded minimum floor under
1145            // `NuGetFormatter`'s comparator, but `deps-core` deliberately still reports it as
1146            // a pin (mirrors `is_requirement_up_to_date`) — see `bare_requirement_policy`'s
1147            // doc for why the always-range alternative was tried and reverted.
1148            deps_core::EcosystemId::NuGet => DeliberateApproximation,
1149        }
1150    }
1151
1152    #[cfg(any(
1153        feature = "cargo",
1154        feature = "npm",
1155        feature = "go",
1156        feature = "bundler",
1157        feature = "dart",
1158        feature = "maven",
1159        feature = "composer",
1160        feature = "gradle",
1161        feature = "nuget",
1162        feature = "deno",
1163        feature = "github-actions",
1164        feature = "gitlab-ci",
1165        feature = "swift",
1166        feature = "pypi"
1167    ))]
1168    #[test]
1169    fn test_concrete_pin_version_agrees_with_formatter_for_bare_version() {
1170        use deps_core::lsp_helpers::RequirementGate;
1171        use deps_core::{ConcreteVersion, VersionReq};
1172
1173        let registry = Arc::new(EcosystemRegistry::new());
1174        let cache = Arc::new(HttpCache::new());
1175        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
1176
1177        const BARE_FULL_VERSION: &str = "1.2.3";
1178        const BARE_PARTIAL_VERSION: &str = "1.2";
1179
1180        for id in registry.ecosystem_ids() {
1181            let ecosystem = registry
1182                .get(id)
1183                .unwrap_or_else(|| panic!("{id:?} came from the registry's own ids"));
1184            let formatter = ecosystem.formatter();
1185
1186            match bare_version_agreement_expectation(id) {
1187                BareVersionAgreementExpectation::Checked { test_partial } => {
1188                    let bare_inputs: &[&str] = if test_partial {
1189                        &[BARE_FULL_VERSION, BARE_PARTIAL_VERSION]
1190                    } else {
1191                        &[BARE_FULL_VERSION]
1192                    };
1193                    for &bare in bare_inputs {
1194                        let deps_core_says_pin =
1195                            deps_core::lsp_helpers::concrete_pin_version(bare, id).is_some();
1196                        let formatter_says_pin =
1197                            formatter_treats_bare_version_as_exact_pin(formatter, bare);
1198
1199                        assert_eq!(
1200                            deps_core_says_pin, formatter_says_pin,
1201                            "{id:?} ({bare:?}): deps-core's concrete_pin_version and the \
1202                             ecosystem's own compile_requirement/version_satisfies_requirement \
1203                             disagree on whether this bare requirement is an exact pin"
1204                        );
1205                    }
1206                }
1207                BareVersionAgreementExpectation::LatentOnly => {
1208                    let deps_core_says_pin =
1209                        deps_core::lsp_helpers::concrete_pin_version(BARE_FULL_VERSION, id)
1210                            .is_some();
1211                    let formatter_says_pin =
1212                        formatter_treats_bare_version_as_exact_pin(formatter, BARE_FULL_VERSION);
1213
1214                    assert_ne!(
1215                        deps_core_says_pin, formatter_says_pin,
1216                        "{id:?}: this ecosystem is exempted as a latent-only mismatch, but its \
1217                         comparator no longer disagrees with deps-core's verdict — either the \
1218                         parser started emitting a bare requirement (making this a live bug, \
1219                         not a latent one) or the comparator changed; re-review this exemption \
1220                         in bare_version_agreement_expectation"
1221                    );
1222                }
1223                BareVersionAgreementExpectation::DeliberateApproximation => {
1224                    assert!(
1225                        deps_core::lsp_helpers::concrete_pin_version(BARE_FULL_VERSION, id)
1226                            .is_some(),
1227                        "{id:?}: deps-core should still report a bare full version as a pin \
1228                         (the deliberate approximation this exemption documents)"
1229                    );
1230                    assert!(
1231                        !formatter_treats_bare_version_as_exact_pin(formatter, BARE_FULL_VERSION),
1232                        "{id:?}: the ecosystem's own comparator no longer disagrees with a \
1233                         strict pin verdict — re-review whether this exemption (and the \
1234                         Concrete-policy approximation it documents) is still needed"
1235                    );
1236
1237                    let requirement = VersionReq::new(BARE_FULL_VERSION);
1238                    assert!(
1239                        formatter.is_requirement_up_to_date(
1240                            &requirement,
1241                            &ConcreteVersion::from(BARE_FULL_VERSION)
1242                        ),
1243                        "{id:?}: is_requirement_up_to_date should treat a bare floor as \
1244                         up to date when latest equals the floor — the pin-like precedent \
1245                         this exemption relies on"
1246                    );
1247                    assert!(
1248                        !formatter.is_requirement_up_to_date(
1249                            &requirement,
1250                            &ConcreteVersion::from("9.9.9")
1251                        ),
1252                        "{id:?}: is_requirement_up_to_date should treat a bare floor as \
1253                         outdated once latest moves past it — confirming it is handled as a \
1254                         pin, not an auto-following range"
1255                    );
1256                }
1257            }
1258        }
1259    }
1260
1261    /// #348 regression: `select_latest_matching` must resolve an all-`AdvisoryDeprecated`
1262    /// version list under a wildcard requirement for every registered ecosystem — an
1263    /// advisory-only flag (npm `deprecated`, Composer `abandoned`, ...) must never make an
1264    /// existing package look unresolvable (#347). Iterates every id `register_ecosystems`
1265    /// wires up via `EcosystemRegistry::ecosystem_ids`, so a 12th ecosystem is covered
1266    /// automatically without a new test. The paired `Available` control guards against a
1267    /// `None` result that has nothing to do with the advisory flag (e.g. the fixture
1268    /// version strings not fitting this ecosystem's matcher).
1269    ///
1270    /// This assertion is only genuinely discriminating for an ecosystem whose
1271    /// `select_latest_matching` actually consults `removal_status()` when filtering under
1272    /// a wildcard requirement (currently Composer, npm, and Deno-via-npm) — for an
1273    /// ecosystem that doesn't filter on it at all, or that only maps a real per-version
1274    /// yank (not the advisory case), `subject.is_some()` is trivially true regardless of
1275    /// whether the ecosystem maps its advisory flag correctly.
1276    #[test]
1277    fn test_select_latest_matching_resolves_advisory_deprecated_for_every_ecosystem() {
1278        use deps_core::{RemovalStatus, Version, VersionReq};
1279        use std::any::Any;
1280
1281        struct StatusVersion {
1282            version: deps_core::ConcreteVersion,
1283            status: RemovalStatus,
1284        }
1285
1286        impl Version for StatusVersion {
1287            fn version_string(&self) -> &deps_core::ConcreteVersion {
1288                &self.version
1289            }
1290
1291            fn removal_status(&self) -> RemovalStatus {
1292                self.status
1293            }
1294
1295            fn as_any(&self) -> &dyn Any {
1296                self
1297            }
1298        }
1299
1300        fn fixture(status: RemovalStatus) -> Vec<Box<dyn Version>> {
1301            vec![
1302                Box::new(StatusVersion {
1303                    version: "2.0.0".into(),
1304                    status,
1305                }),
1306                Box::new(StatusVersion {
1307                    version: "1.2.3".into(),
1308                    status,
1309                }),
1310            ]
1311        }
1312
1313        // #421 S2: a package whose only releases are prerelease must still resolve under a
1314        // wildcard requirement — prerelease is a ranking preference, not a removal from
1315        // existence. `is_prerelease()` is overridden directly so this fixture stays
1316        // unambiguous regardless of ecosystem-specific version-string parsing.
1317        struct PrereleaseOnlyVersion {
1318            version: deps_core::ConcreteVersion,
1319        }
1320
1321        impl Version for PrereleaseOnlyVersion {
1322            fn version_string(&self) -> &deps_core::ConcreteVersion {
1323                &self.version
1324            }
1325
1326            fn is_prerelease(&self) -> bool {
1327                true
1328            }
1329
1330            fn as_any(&self) -> &dyn Any {
1331                self
1332            }
1333        }
1334
1335        fn prerelease_only_fixture() -> Vec<Box<dyn Version>> {
1336            vec![
1337                Box::new(PrereleaseOnlyVersion {
1338                    version: "2.0.0-beta2".into(),
1339                }),
1340                Box::new(PrereleaseOnlyVersion {
1341                    version: "2.0.0-beta1".into(),
1342                }),
1343            ]
1344        }
1345
1346        let registry = Arc::new(EcosystemRegistry::new());
1347        let cache = Arc::new(HttpCache::new());
1348        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
1349
1350        let req = VersionReq::new("*");
1351        for id in registry.ecosystem_ids() {
1352            let ecosystem = registry.get(id).expect("id came from ecosystem_ids()");
1353            let ecosystem_registry = ecosystem.registry();
1354
1355            let control = ecosystem_registry.select_latest_matching(
1356                &fixture(RemovalStatus::Available),
1357                &req,
1358                &deps_core::SelectionContext::none(),
1359            );
1360            assert!(
1361                control.is_some(),
1362                "{id}: control fixture (all Available) must resolve under a wildcard \
1363                 requirement — a `None` here means the fixture itself doesn't fit this \
1364                 ecosystem's matcher, not that the advisory flag broke anything"
1365            );
1366
1367            let subject = ecosystem_registry.select_latest_matching(
1368                &fixture(RemovalStatus::AdvisoryDeprecated),
1369                &req,
1370                &deps_core::SelectionContext::none(),
1371            );
1372            assert!(
1373                subject.is_some(),
1374                "{id}: an advisory-only flag must not hide an existing package under a \
1375                 wildcard requirement (#347)"
1376            );
1377
1378            // Go is a deliberate exception, not an #421-class bug (#364): `select_latest_matching`
1379            // unconditionally excludes prerelease pseudo-versions with no wildcard fallback, so
1380            // the `/@v/list` pick never shadows the `/@latest` fallback a prerelease-only module
1381            // needs. Asserting this invariant for Go would "fix" intentional behavior.
1382            //
1383            // NuGet used to be excluded too, but #423 added a fallback rung to
1384            // `select_latest_matching` so a prerelease-only package now resolves under a bare
1385            // wildcard too — no exception needed anymore.
1386            if matches!(id, deps_core::EcosystemId::Go) {
1387                continue;
1388            }
1389
1390            let prerelease_subject = ecosystem_registry.select_latest_matching(
1391                &prerelease_only_fixture(),
1392                &req,
1393                &deps_core::SelectionContext::none(),
1394            );
1395            assert!(
1396                prerelease_subject.is_some(),
1397                "{id}: a package whose only releases so far are prerelease must still \
1398                 resolve under a wildcard requirement (#421)"
1399            );
1400        }
1401    }
1402}