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(®istry, 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<&'static str> {
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 _ = (®istry, &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("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("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("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("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("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("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("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("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(®istry, 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(®istry, 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 = workspace_registry_ecosystems.clone();
778 actual.sort_unstable();
779 assert_eq!(
780 actual, expected,
781 "workspace-registry-policy ecosystem set changed — update this test's `expected` \
782 list alongside whatever registration change caused it"
783 );
784 }
785
786 /// Layer 1a (#758): completeness — every [`deps_core::EcosystemId::ALL`] variant must
787 /// actually be registered by [`register_ecosystems`]. Driven by `ALL` itself, not
788 /// `registry.ecosystem_ids()`, so an ecosystem declared in the enum but never wired in
789 /// fails this test instead of silently vanishing from coverage.
790 ///
791 /// Gated on every ecosystem feature at once: `ALL` always lists 14 variants regardless of
792 /// which features are enabled for this build, so this specific claim — "all 14 are
793 /// present" — is only meaningful, and only makes sense to check, in an all-features build.
794 /// The per-ecosystem invariants that don't depend on all 14 being present live in the
795 /// ungated [`test_registered_ecosystems_universal_invariants`] instead (#758 impl-critic
796 /// S2): unlike this completeness check, those must keep working under any feature subset,
797 /// the way the two hand-written per-feature lists this pair replaces used to.
798 #[cfg(all(
799 feature = "cargo",
800 feature = "npm",
801 feature = "pypi",
802 feature = "go",
803 feature = "bundler",
804 feature = "dart",
805 feature = "maven",
806 feature = "gradle",
807 feature = "swift",
808 feature = "composer",
809 feature = "nuget",
810 feature = "deno",
811 feature = "github-actions",
812 feature = "gitlab-ci"
813 ))]
814 #[test]
815 fn test_ecosystem_id_all_registered() {
816 let registry = Arc::new(EcosystemRegistry::new());
817 let cache = Arc::new(HttpCache::new());
818 register_ecosystems(®istry, Arc::clone(&cache), &test_runtime());
819
820 for id in deps_core::EcosystemId::ALL {
821 assert!(
822 registry.get(id.id()).is_some(),
823 "{id:?} is in EcosystemId::ALL but was not registered by register_ecosystems"
824 );
825 }
826 }
827
828 /// Layer 1b (#758): universal, offline invariants every ecosystem this build actually
829 /// registers must satisfy. Deliberately **ungated** — unlike
830 /// [`test_ecosystem_id_all_registered`]'s completeness claim, none of these invariants
831 /// depend on all 14 ecosystems being present, so this iterates whatever
832 /// `registry.ecosystem_ids()` this build's feature set produced (#758 impl-critic S2):
833 /// under `--no-default-features --features npm`, it still covers `npm` alone; under the
834 /// default (all 14) build, it covers all 14, same as before the split.
835 ///
836 /// Per ecosystem: id round-trip; `display_name()` non-empty and unique across the set; at
837 /// least one routing surface non-empty; `lockfile_filenames().is_empty() ==
838 /// lockfile_provider().is_none()`; `package_url` hostile-input safety (display sink only —
839 /// see `deps_core::conformance`'s doc for the display-vs-fetch sink split; does **not**
840 /// prove the URL is non-degenerate, that is `formatter_conformance!`'s job per ecosystem);
841 /// `completion_insert_text` does not panic.
842 #[test]
843 fn test_registered_ecosystems_universal_invariants() {
844 let registry = Arc::new(EcosystemRegistry::new());
845 let cache = Arc::new(HttpCache::new());
846 register_ecosystems(®istry, Arc::clone(&cache), &test_runtime());
847
848 let mut display_names = std::collections::HashSet::new();
849
850 for id in registry.ecosystem_ids() {
851 let ecosystem = registry
852 .get(id)
853 .unwrap_or_else(|| panic!("{id:?} came from registry.ecosystem_ids() itself"));
854
855 let parsed_id: deps_core::EcosystemId = id.parse().unwrap_or_else(|_| {
856 panic!("{id:?} has no matching EcosystemId variant (see issue #118)")
857 });
858 assert_eq!(
859 parsed_id.id(),
860 id,
861 "{id:?}: EcosystemId round-trip mismatch"
862 );
863 assert_eq!(ecosystem.id(), id, "{id:?}: Ecosystem::id() mismatch");
864
865 let display_name = ecosystem.display_name();
866 assert!(!display_name.is_empty(), "{id:?} has an empty display_name");
867 assert!(
868 display_names.insert(display_name),
869 "{id:?}'s display_name {display_name:?} collides with another ecosystem's"
870 );
871
872 assert!(
873 !ecosystem.manifest_filenames().is_empty()
874 || !ecosystem.manifest_patterns().is_empty()
875 || !ecosystem.manifest_extensions().is_empty()
876 || !ecosystem.manifest_directory_patterns().is_empty(),
877 "{id:?} has no routing surface at all (manifest_filenames/patterns/extensions/directory_patterns)"
878 );
879
880 assert_eq!(
881 ecosystem.lockfile_filenames().is_empty(),
882 ecosystem.lockfile_provider().is_none(),
883 "{id:?}: lockfile_filenames()/lockfile_provider() disagree on whether a lock file format exists"
884 );
885
886 // Hostile-input safety for the `package_url` display sink (#758 security-review),
887 // shared with `formatter_conformance!`'s per-crate check (#782 gap 1) — see
888 // `assert_package_url_hostile_input_safe`'s doc for the full rationale.
889 deps_core::conformance::assert_package_url_hostile_input_safe(
890 ecosystem.formatter(),
891 &format!("{id:?}"),
892 );
893
894 let metadata = deps_core::test_util::MockMetadata::new("conformance-probe", "1.0.0");
895 let _ = ecosystem.completion_insert_text(&metadata);
896 }
897 }
898
899 /// CRITICAL regression (issue #706 review): GitHub Actions' `action.yml`/`action.yaml`
900 /// bare-basename routing and GitLab CI's `.gitlab/ci/*.yml` directory-pattern routing
901 /// can both match `.gitlab/ci/action.yml` — before `EcosystemRegistry::for_uri`'s
902 /// fix (deps-core), the basename match was checked first and always won, silently
903 /// routing a real GitLab CI file to `github-actions` (which would then, on top of
904 /// that, degrade it to zero dependencies since it lacks a top-level `runs:` key —
905 /// total, silent loss of hover/diagnostics/completions for the file). Exercises the
906 /// real production registry both real ecosystem crates are wired into, not a mock.
907 #[cfg(all(feature = "github-actions", feature = "gitlab-ci"))]
908 #[test]
909 fn test_gitlab_ci_directory_pattern_wins_over_github_actions_basename_match() {
910 let registry = Arc::new(EcosystemRegistry::new());
911 let cache = Arc::new(HttpCache::new());
912 register_ecosystems(®istry, Arc::clone(&cache), &test_runtime());
913
914 let uri = deps_core::test_util::test_uri("/repo/.gitlab/ci/action.yml");
915 assert_eq!(
916 registry.for_uri(&uri).map(|e| e.id()),
917 Some(deps_core::EcosystemId::GitlabCi.id()),
918 "a real .gitlab/ci/action.yml file must route to gitlab-ci, not github-actions"
919 );
920
921 // Non-conflicting action.yml locations must be unaffected.
922 let root_action = deps_core::test_util::test_uri("/repo/action.yml");
923 assert_eq!(
924 registry.for_uri(&root_action).map(|e| e.id()),
925 Some(deps_core::EcosystemId::GithubActions.id())
926 );
927 let nested_action =
928 deps_core::test_util::test_uri("/repo/.github/actions/my-action/action.yml");
929 assert_eq!(
930 registry.for_uri(&nested_action).map(|e| e.id()),
931 Some(deps_core::EcosystemId::GithubActions.id())
932 );
933 }
934
935 /// Whether `formatter`'s own comparator (preferring
936 /// [`deps_core::lsp_helpers::RequirementResolution::compile_requirement`], falling back to
937 /// [`deps_core::lsp_helpers::RequirementResolution::version_satisfies_requirement`] when it
938 /// declines to compile) treats a bare `requirement` as an exact pin: it must match
939 /// `requirement` itself but reject both a higher patch (`"1.2.9"`) and a higher
940 /// minor/major (`"9.9.9"`) — the same "matches only this one version" shape
941 /// [`deps_core::lsp_helpers::concrete_pin_version`] asserts. Also correctly says `false`
942 /// for a genuine partial-version range (e.g. `"1.2"`), since that legitimately matches
943 /// more than one candidate.
944 #[cfg(any(
945 feature = "cargo",
946 feature = "npm",
947 feature = "go",
948 feature = "bundler",
949 feature = "dart",
950 feature = "maven",
951 feature = "composer",
952 feature = "gradle",
953 feature = "nuget",
954 feature = "deno",
955 feature = "github-actions",
956 feature = "gitlab-ci",
957 feature = "swift",
958 feature = "pypi"
959 ))]
960 fn formatter_treats_bare_version_as_exact_pin(
961 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
962 bare: &str,
963 ) -> bool {
964 use deps_core::{ConcreteVersion, VersionReq};
965
966 let requirement = VersionReq::new(bare);
967 let matches = |candidate: &str| -> bool {
968 let version = ConcreteVersion::from(candidate);
969 if let Some(matcher) = formatter.compile_requirement(&requirement) {
970 matcher.matches(&version) == Some(true)
971 } else {
972 formatter.version_satisfies_requirement(&version, bare)
973 }
974 };
975
976 matches(bare) && !matches("9.9.9") && !matches("1.2.9")
977 }
978
979 /// How a given ecosystem's bare-version-is-a-pin verdict relates to its own formatter's
980 /// comparator — see [`bare_version_agreement_expectation`].
981 #[cfg(any(
982 feature = "cargo",
983 feature = "npm",
984 feature = "go",
985 feature = "bundler",
986 feature = "dart",
987 feature = "maven",
988 feature = "composer",
989 feature = "gradle",
990 feature = "nuget",
991 feature = "deno",
992 feature = "github-actions",
993 feature = "gitlab-ci",
994 feature = "swift",
995 feature = "pypi"
996 ))]
997 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
998 enum BareVersionAgreementExpectation {
999 /// `deps-core`'s `concrete_pin_version` and the ecosystem's own comparator must
1000 /// agree on whether a bare full version (`"1.2.3"`) is an exact pin. When `true`,
1001 /// also checked against a bare *partial* version (`"1.2"`, impl-critic M1) —
1002 /// gated per-ecosystem because a partial-version *requirement* is not a concept
1003 /// every `Concrete`-policy ecosystem actually has: Go's own comparator, for
1004 /// instance, treats a bare string as a version *prefix* to support pseudo-version
1005 /// and `+incompatible`-suffix matching (`go_version_matches`), which makes it
1006 /// (correctly, for its real purpose) accept `"1.2"` against a candidate `"1.2.9"`
1007 /// — a false "divergence" against `deps-core` if compared as though `"1.2"` were
1008 /// a genuine partial-version requirement, which go.mod's `require` directive
1009 /// never actually contains (it is always a complete version). Only the
1010 /// ecosystems with a real bare-partial-is-a-range grammar (`AlwaysRange`'s Cargo
1011 /// caret, `ConcreteIfFullVersion`'s X-range/moving-tag ecosystems) get `true`.
1012 Checked { test_partial: bool },
1013 /// The ecosystem's own comparator would disagree with `deps-core`'s verdict, but
1014 /// the parser can never actually emit a bare requirement in the first place, so the
1015 /// divergence never reaches `concrete_pin_version` in practice (Swift, PyPI).
1016 LatentOnly,
1017 /// `deps-core` deliberately reports a bare requirement as a pin even though the
1018 /// ecosystem's own comparator disagrees — a documented approximation, not an
1019 /// oversight (NuGet).
1020 DeliberateApproximation,
1021 }
1022
1023 /// #669 regression guard: `deps-core`'s `bare_requirement_policy` hand-maintains a
1024 /// per-ecosystem model of "is a bare version requirement a pin or a range", but every
1025 /// ecosystem's own formatter already has the authoritative answer via
1026 /// `compile_requirement`/`version_satisfies_requirement`, and nothing kept the two in
1027 /// sync — this already caused two shipped bugs (#664 npm/Composer, #667 Deno). For every
1028 /// ecosystem this crate can register, classifies it via [`BareVersionAgreementExpectation`]
1029 /// and checks the matching invariant: `Checked` ecosystems must agree on both a bare full
1030 /// version (`"1.2.3"`) and a bare partial version (`"1.2"`, impl-critic M1); `LatentOnly`
1031 /// and `DeliberateApproximation` ecosystems must instead still exhibit the disagreement
1032 /// their exemption relies on (impl-critic M2) — so an alignment on either side (a parser
1033 /// change, a comparator change) fails this test instead of silently going stale.
1034 ///
1035 /// The `match` in [`bare_version_agreement_expectation`] is deliberately exhaustive
1036 /// (`.claude/CLAUDE.md`'s bug-class-#118 rule): a future 15th ecosystem must get an
1037 /// explicit arm — added to `Checked` or listed as a commented, reviewed exemption —
1038 /// rather than silently falling through a wildcard.
1039 #[cfg(any(
1040 feature = "cargo",
1041 feature = "npm",
1042 feature = "go",
1043 feature = "bundler",
1044 feature = "dart",
1045 feature = "maven",
1046 feature = "composer",
1047 feature = "gradle",
1048 feature = "nuget",
1049 feature = "deno",
1050 feature = "github-actions",
1051 feature = "gitlab-ci",
1052 feature = "swift",
1053 feature = "pypi"
1054 ))]
1055 fn bare_version_agreement_expectation(
1056 id: deps_core::EcosystemId,
1057 ) -> BareVersionAgreementExpectation {
1058 use BareVersionAgreementExpectation::{Checked, DeliberateApproximation, LatentOnly};
1059
1060 match id {
1061 // Cargo and the `ConcreteIfFullVersion` ecosystems: a bare partial version is a
1062 // real, distinct requirement shape from a bare full version, so both are checked.
1063 deps_core::EcosystemId::Cargo
1064 | deps_core::EcosystemId::Npm
1065 | deps_core::EcosystemId::Composer
1066 | deps_core::EcosystemId::Deno
1067 | deps_core::EcosystemId::GithubActions
1068 | deps_core::EcosystemId::GitlabCi => Checked { test_partial: true },
1069 // Go/Bundler/Dart/Maven/Gradle: no partial-version requirement concept exists (a
1070 // bare version is always complete), so only the full-version case is checked. Go's
1071 // comparator specifically treats a bare string as a version prefix, not a
1072 // partial-range, so testing `"1.2"` there would produce a false divergence.
1073 deps_core::EcosystemId::Go
1074 | deps_core::EcosystemId::Bundler
1075 | deps_core::EcosystemId::Dart
1076 | deps_core::EcosystemId::Maven
1077 | deps_core::EcosystemId::Gradle => Checked {
1078 test_partial: false,
1079 },
1080 // Swift: `compile_requirement`'s `semver::VersionReq` has the same bare-string
1081 // caret-range divergence as NuGet, but `deps-swift`'s parser always emits an
1082 // explicit range or exact pin, never a bare `"X.Y.Z"` — can't fire today.
1083 // Re-review if the parser ever changes to emit a bare form.
1084 deps_core::EcosystemId::Swift => LatentOnly,
1085 // PyPI: the parser retains the pep440 comparator on every requirement (an exact
1086 // pin parses to `"==1.2.3"`, never bare), so a bare requirement never reaches
1087 // this check. Re-review if the parser ever changes to emit a bare form.
1088 deps_core::EcosystemId::Pypi => LatentOnly,
1089 // NuGet (#669): a bare `Version="X"` is really an unbounded minimum floor under
1090 // `NuGetFormatter`'s comparator, but `deps-core` deliberately still reports it as
1091 // a pin (mirrors `is_requirement_up_to_date`) — see `bare_requirement_policy`'s
1092 // doc for why the always-range alternative was tried and reverted.
1093 deps_core::EcosystemId::NuGet => DeliberateApproximation,
1094 }
1095 }
1096
1097 #[cfg(any(
1098 feature = "cargo",
1099 feature = "npm",
1100 feature = "go",
1101 feature = "bundler",
1102 feature = "dart",
1103 feature = "maven",
1104 feature = "composer",
1105 feature = "gradle",
1106 feature = "nuget",
1107 feature = "deno",
1108 feature = "github-actions",
1109 feature = "gitlab-ci",
1110 feature = "swift",
1111 feature = "pypi"
1112 ))]
1113 #[test]
1114 fn test_concrete_pin_version_agrees_with_formatter_for_bare_version() {
1115 use deps_core::{ConcreteVersion, VersionReq};
1116
1117 let registry = Arc::new(EcosystemRegistry::new());
1118 let cache = Arc::new(HttpCache::new());
1119 register_ecosystems(®istry, Arc::clone(&cache), &test_runtime());
1120
1121 const BARE_FULL_VERSION: &str = "1.2.3";
1122 const BARE_PARTIAL_VERSION: &str = "1.2";
1123
1124 for str_id in registry.ecosystem_ids() {
1125 let id: deps_core::EcosystemId = str_id.parse().unwrap_or_else(|_| {
1126 panic!("registered ecosystem id {str_id:?} has no matching EcosystemId variant")
1127 });
1128 let ecosystem = registry
1129 .get(str_id)
1130 .unwrap_or_else(|| panic!("{str_id:?} just parsed from the registry's own ids"));
1131 let formatter = ecosystem.formatter();
1132
1133 match bare_version_agreement_expectation(id) {
1134 BareVersionAgreementExpectation::Checked { test_partial } => {
1135 let bare_inputs: &[&str] = if test_partial {
1136 &[BARE_FULL_VERSION, BARE_PARTIAL_VERSION]
1137 } else {
1138 &[BARE_FULL_VERSION]
1139 };
1140 for &bare in bare_inputs {
1141 let deps_core_says_pin =
1142 deps_core::lsp_helpers::concrete_pin_version(bare, id).is_some();
1143 let formatter_says_pin =
1144 formatter_treats_bare_version_as_exact_pin(formatter, bare);
1145
1146 assert_eq!(
1147 deps_core_says_pin, formatter_says_pin,
1148 "{id:?} ({bare:?}): deps-core's concrete_pin_version and the \
1149 ecosystem's own compile_requirement/version_satisfies_requirement \
1150 disagree on whether this bare requirement is an exact pin"
1151 );
1152 }
1153 }
1154 BareVersionAgreementExpectation::LatentOnly => {
1155 let deps_core_says_pin =
1156 deps_core::lsp_helpers::concrete_pin_version(BARE_FULL_VERSION, id)
1157 .is_some();
1158 let formatter_says_pin =
1159 formatter_treats_bare_version_as_exact_pin(formatter, BARE_FULL_VERSION);
1160
1161 assert_ne!(
1162 deps_core_says_pin, formatter_says_pin,
1163 "{id:?}: this ecosystem is exempted as a latent-only mismatch, but its \
1164 comparator no longer disagrees with deps-core's verdict — either the \
1165 parser started emitting a bare requirement (making this a live bug, \
1166 not a latent one) or the comparator changed; re-review this exemption \
1167 in bare_version_agreement_expectation"
1168 );
1169 }
1170 BareVersionAgreementExpectation::DeliberateApproximation => {
1171 assert!(
1172 deps_core::lsp_helpers::concrete_pin_version(BARE_FULL_VERSION, id)
1173 .is_some(),
1174 "{id:?}: deps-core should still report a bare full version as a pin \
1175 (the deliberate approximation this exemption documents)"
1176 );
1177 assert!(
1178 !formatter_treats_bare_version_as_exact_pin(formatter, BARE_FULL_VERSION),
1179 "{id:?}: the ecosystem's own comparator no longer disagrees with a \
1180 strict pin verdict — re-review whether this exemption (and the \
1181 Concrete-policy approximation it documents) is still needed"
1182 );
1183
1184 let requirement = VersionReq::new(BARE_FULL_VERSION);
1185 assert!(
1186 formatter.is_requirement_up_to_date(
1187 &requirement,
1188 &ConcreteVersion::from(BARE_FULL_VERSION)
1189 ),
1190 "{id:?}: is_requirement_up_to_date should treat a bare floor as \
1191 up to date when latest equals the floor — the pin-like precedent \
1192 this exemption relies on"
1193 );
1194 assert!(
1195 !formatter.is_requirement_up_to_date(
1196 &requirement,
1197 &ConcreteVersion::from("9.9.9")
1198 ),
1199 "{id:?}: is_requirement_up_to_date should treat a bare floor as \
1200 outdated once latest moves past it — confirming it is handled as a \
1201 pin, not an auto-following range"
1202 );
1203 }
1204 }
1205 }
1206 }
1207
1208 /// #348 regression: `select_latest_matching` must resolve an all-`AdvisoryDeprecated`
1209 /// version list under a wildcard requirement for every registered ecosystem — an
1210 /// advisory-only flag (npm `deprecated`, Composer `abandoned`, ...) must never make an
1211 /// existing package look unresolvable (#347). Iterates every id `register_ecosystems`
1212 /// wires up via `EcosystemRegistry::ecosystem_ids`, so a 12th ecosystem is covered
1213 /// automatically without a new test. The paired `Available` control guards against a
1214 /// `None` result that has nothing to do with the advisory flag (e.g. the fixture
1215 /// version strings not fitting this ecosystem's matcher).
1216 ///
1217 /// This assertion is only genuinely discriminating for an ecosystem whose
1218 /// `select_latest_matching` actually consults `removal_status()` when filtering under
1219 /// a wildcard requirement (currently Composer, npm, and Deno-via-npm) — for an
1220 /// ecosystem that doesn't filter on it at all, or that only maps a real per-version
1221 /// yank (not the advisory case), `subject.is_some()` is trivially true regardless of
1222 /// whether the ecosystem maps its advisory flag correctly.
1223 #[test]
1224 fn test_select_latest_matching_resolves_advisory_deprecated_for_every_ecosystem() {
1225 use deps_core::{RemovalStatus, Version, VersionReq};
1226 use std::any::Any;
1227
1228 struct StatusVersion {
1229 version: deps_core::ConcreteVersion,
1230 status: RemovalStatus,
1231 }
1232
1233 impl Version for StatusVersion {
1234 fn version_string(&self) -> &deps_core::ConcreteVersion {
1235 &self.version
1236 }
1237
1238 fn removal_status(&self) -> RemovalStatus {
1239 self.status
1240 }
1241
1242 fn as_any(&self) -> &dyn Any {
1243 self
1244 }
1245 }
1246
1247 fn fixture(status: RemovalStatus) -> Vec<Box<dyn Version>> {
1248 vec![
1249 Box::new(StatusVersion {
1250 version: "2.0.0".into(),
1251 status,
1252 }),
1253 Box::new(StatusVersion {
1254 version: "1.2.3".into(),
1255 status,
1256 }),
1257 ]
1258 }
1259
1260 // #421 S2: a package whose only releases are prerelease must still resolve under a
1261 // wildcard requirement — prerelease is a ranking preference, not a removal from
1262 // existence. `is_prerelease()` is overridden directly so this fixture stays
1263 // unambiguous regardless of ecosystem-specific version-string parsing.
1264 struct PrereleaseOnlyVersion {
1265 version: deps_core::ConcreteVersion,
1266 }
1267
1268 impl Version for PrereleaseOnlyVersion {
1269 fn version_string(&self) -> &deps_core::ConcreteVersion {
1270 &self.version
1271 }
1272
1273 fn is_prerelease(&self) -> bool {
1274 true
1275 }
1276
1277 fn as_any(&self) -> &dyn Any {
1278 self
1279 }
1280 }
1281
1282 fn prerelease_only_fixture() -> Vec<Box<dyn Version>> {
1283 vec![
1284 Box::new(PrereleaseOnlyVersion {
1285 version: "2.0.0-beta2".into(),
1286 }),
1287 Box::new(PrereleaseOnlyVersion {
1288 version: "2.0.0-beta1".into(),
1289 }),
1290 ]
1291 }
1292
1293 let registry = Arc::new(EcosystemRegistry::new());
1294 let cache = Arc::new(HttpCache::new());
1295 register_ecosystems(®istry, Arc::clone(&cache), &test_runtime());
1296
1297 let req = VersionReq::new("*");
1298 for id in registry.ecosystem_ids() {
1299 let ecosystem = registry.get(id).expect("id came from ecosystem_ids()");
1300 let ecosystem_registry = ecosystem.registry();
1301
1302 let control =
1303 ecosystem_registry.select_latest_matching(&fixture(RemovalStatus::Available), &req);
1304 assert!(
1305 control.is_some(),
1306 "{id}: control fixture (all Available) must resolve under a wildcard \
1307 requirement — a `None` here means the fixture itself doesn't fit this \
1308 ecosystem's matcher, not that the advisory flag broke anything"
1309 );
1310
1311 let subject = ecosystem_registry
1312 .select_latest_matching(&fixture(RemovalStatus::AdvisoryDeprecated), &req);
1313 assert!(
1314 subject.is_some(),
1315 "{id}: an advisory-only flag must not hide an existing package under a \
1316 wildcard requirement (#347)"
1317 );
1318
1319 // Go is a deliberate exception, not an #421-class bug (#364): `select_latest_matching`
1320 // unconditionally excludes prerelease pseudo-versions with no wildcard fallback, so
1321 // the `/@v/list` pick never shadows the `/@latest` fallback a prerelease-only module
1322 // needs. Asserting this invariant for Go would "fix" intentional behavior.
1323 //
1324 // NuGet used to be excluded too, but #423 added a fallback rung to
1325 // `select_latest_matching` so a prerelease-only package now resolves under a bare
1326 // wildcard too — no exception needed anymore.
1327 if matches!(id, "go") {
1328 continue;
1329 }
1330
1331 let prerelease_subject =
1332 ecosystem_registry.select_latest_matching(&prerelease_only_fixture(), &req);
1333 assert!(
1334 prerelease_subject.is_some(),
1335 "{id}: a package whose only releases so far are prerelease must still \
1336 resolve under a wildcard requirement (#421)"
1337 );
1338 }
1339 }
1340}