Skip to main content

deps_core/lsp_helpers/
in_use_version.rs

1//! "What version does this dependency occurrence actually have" — shared by
2//! `deps-lsp`'s registry-fetch/OSV-target pipeline and, for #394's S1 fix,
3//! the yanked-diagnostic consistency check in [`super::diagnostics`].
4
5use std::collections::HashMap;
6
7use crate::lsp_helpers::EcosystemFormatter;
8use crate::{ConcreteVersion, Dependency, EcosystemId, PackageName};
9
10/// How a *bare* (no explicit pin marker) version requirement should be treated when
11/// deciding whether it denotes a single concrete version.
12///
13/// Replaces a plain boolean (critique B2 of #208's plan) because neither `true` nor
14/// `false` is correct for GitHub Actions: `AlwaysRange`/`Concrete` alone cannot express
15/// "a bare `v4` is a range, but a bare `v4.2.0` is a pin" — the two forms share no
16/// syntactic marker to distinguish them by, only the number of components present.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum BareRequirementPolicy {
19    /// A bare requirement is always a range under this ecosystem's own default
20    /// semantics (Cargo's implicit caret, npm/Composer's implicit caret) — never
21    /// treated as concrete without an explicit `=`/`==` pin marker.
22    AlwaysRange,
23    /// A bare requirement is concrete only when it has the shape of a full
24    /// `major.minor.patch` version ([`is_full_semver_shape`]); a partial form (a bare
25    /// major or major.minor, e.g. GitHub Actions' moving-major `v4` tag) is treated as
26    /// a range instead, since it is one.
27    ConcreteIfFullVersion,
28    /// A bare requirement is already exact (no implicit range operator).
29    Concrete,
30}
31
32/// Ecosystems whose *bare* (no explicit pin marker) version requirement is a
33/// range under that ecosystem's own default semantics — Cargo's implicit
34/// caret, npm/Composer's implicit caret. For these, [`is_concrete_version`]
35/// requires an explicit `=`/`==` (or an exact-bracket wrap) before treating a
36/// requirement as concrete; a bare `"1.2.3"` alone is not enough evidence
37/// (critique C2).
38///
39/// Deno reuses npm's exact grammar for both its `jsr:` and `npm:` specifiers
40/// (`DenoFormatter::compile_requirement` compiles both through the same
41/// `node_semver::Range` npm itself uses), so it gets the same treatment here.
42///
43/// GitHub Actions gets [`BareRequirementPolicy::ConcreteIfFullVersion`]: a bare `v4`
44/// (a moving-major tag) genuinely is a range, so it must not be queried as if it were
45/// the concrete version `4`, but a bare `v4.2.0` is a pin — see
46/// [`BareRequirementPolicy`]'s docs. A bare 40-character SHA also falls to the
47/// `None` side of this gate ([`is_full_semver_shape`] rejects it), which is the
48/// correct "honest unknown" outcome: resolving a SHA to its tag would need registry
49/// access this pure function does not have.
50///
51/// GitLab CI gets the identical policy for the identical reason: a `component:`
52/// include's partial-semver pin (`1`, `1.2`) is a range exactly like GitHub Actions'
53/// moving-major tag ([`is_full_semver_shape`] correctly rejects it, since it requires
54/// all three components), while a full `1.2.3`/`v1.2.3` tag or release-name pin is
55/// concrete. A SHA pin (`project:`'s or `component:`'s) falls to the same honest
56/// "unknown" `None` as GitHub Actions' bare SHA, and `~latest`/a branch-shaped ref
57/// never look like a full version shape either, so both also correctly fall through
58/// to `None`.
59///
60/// Gradle is deliberately excluded: a bare Gradle coordinate version (e.g.
61/// `"2.14.1"`) is an exact match under `GradleFormatter`'s own
62/// `version_satisfies_requirement` unless it uses the `+` dynamic-version
63/// suffix, which [`looks_like_a_single_version`] already rejects via its
64/// reject-char set — Gradle has no implicit-caret default the way
65/// Cargo/npm/Composer do.
66const fn bare_requirement_policy(ecosystem: EcosystemId) -> BareRequirementPolicy {
67    match ecosystem {
68        EcosystemId::Cargo | EcosystemId::Npm | EcosystemId::Composer | EcosystemId::Deno => {
69            BareRequirementPolicy::AlwaysRange
70        }
71        EcosystemId::GithubActions | EcosystemId::GitlabCi => {
72            BareRequirementPolicy::ConcreteIfFullVersion
73        }
74        _ => BareRequirementPolicy::Concrete,
75    }
76}
77
78/// Whether `s` has the shape of a full `major.minor.patch` version.
79///
80/// An optional leading `v`/`V`, three dot-separated all-digit components, and an
81/// optional SemVer-style prerelease/build suffix introduced by `-` or `+` (accepted,
82/// but not itself validated beyond "starts here").
83///
84/// Hand-rolled rather than pulled in via the `regex` crate: this is consulted from
85/// `bare_requirement_policy` in `deps-core`, the workspace's most-depended-on crate,
86/// which has no `regex` dependency today — equivalent to the pattern
87/// `^v?\d+\.\d+\.\d+(?:[-+].*)?$`. Shared verbatim by `deps-github-actions`'s
88/// SHA-comment-tag parsing rule so the two mechanisms can never silently diverge on
89/// what counts as a full version (e.g. `v4.2.0-beta.1` must be treated identically by
90/// both).
91///
92/// # Examples
93///
94/// ```
95/// use deps_core::lsp_helpers::is_full_semver_shape;
96///
97/// assert!(is_full_semver_shape("v4.2.0"));
98/// assert!(is_full_semver_shape("4.2.0-beta.1"));
99/// assert!(!is_full_semver_shape("v4"));
100/// assert!(!is_full_semver_shape("v4.2"));
101/// assert!(!is_full_semver_shape("not-a-version"));
102/// ```
103#[must_use]
104pub fn is_full_semver_shape(s: &str) -> bool {
105    let s = s.strip_prefix(['v', 'V']).unwrap_or(s);
106    let core = match s.find(['-', '+']) {
107        Some(idx) => &s[..idx],
108        None => s,
109    };
110    let mut parts = core.split('.');
111    let (Some(major), Some(minor), Some(patch), None) =
112        (parts.next(), parts.next(), parts.next(), parts.next())
113    else {
114        return false;
115    };
116    [major, minor, patch]
117        .iter()
118        .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
119}
120
121/// Returns `true` if `s` (already stripped of any pin marker) has the shape
122/// of a single concrete version: non-empty, no wildcard/range-operator
123/// character, and starting with a digit (after an optional `v`/`V` prefix,
124/// e.g. Go's `v1.9.1`).
125///
126/// Deliberately conservative — see [`is_concrete_version`]'s doc for why a
127/// false positive here is worse than a false negative.
128fn looks_like_a_single_version(s: &str) -> bool {
129    if s.is_empty() {
130        return false;
131    }
132    if s.contains([
133        '^', '~', '*', '<', '>', ',', '|', '(', ')', '[', ']', ' ', '\t', ':', '+', 'x', 'X',
134    ]) {
135        return false;
136    }
137    let core = s.strip_prefix(['v', 'V']).unwrap_or(s);
138    core.chars().next().is_some_and(|c| c.is_ascii_digit())
139}
140
141/// Returns the concrete version text `requirement` denotes, or `None` if
142/// `requirement` is not the shape of a single concrete version.
143///
144/// Any pin marker (`=`/`==`, or a single-value bracket wrap like NuGet's
145/// `[1.0.0]`) is stripped off. The only shape safe to query OSV with
146/// directly, and, for #233, the only shape safe to compare against a real
147/// registry version string in the yanked-version probe. A wrong answer here
148/// is invisible in testing (OSV silently returns `{}` for a fabricated
149/// version; the yanked probe silently finds no match), so getting this
150/// right matters more than covering every ecosystem's full range grammar.
151///
152/// An explicit pin marker is always accepted, and its marker is stripped
153/// from the returned text — required because PyPI's parser retains the
154/// pep440 comparator in `Dependency::version_requirement()` (an exact pin
155/// parses to `"==4.9.0"`, not `"4.9.0"`; confirmed by
156/// `deps-pypi`'s `test_basic_pinned`), so comparing the *unstripped* text
157/// against a real registry version string (`"4.9.0"`) would never match. A
158/// *bare* requirement (no marker) is returned verbatim, and is accepted only
159/// for ecosystems where a bare version is not itself a range by default
160/// (critique C2) — see `bare_version_is_a_range`.
161///
162/// # Examples
163///
164/// ```
165/// use deps_core::EcosystemId;
166/// use deps_core::lsp_helpers::concrete_pin_version;
167///
168/// // An explicit pin marker is stripped, for any ecosystem.
169/// assert_eq!(
170///     concrete_pin_version("=1.2.3", EcosystemId::Cargo),
171///     Some("1.2.3")
172/// );
173///
174/// // Cargo's bare version is a caret range by default, not a pin.
175/// assert_eq!(concrete_pin_version("1.2.3", EcosystemId::Cargo), None);
176///
177/// // Maven has no implicit range operator, so a bare version is exact.
178/// assert_eq!(
179///     concrete_pin_version("2.14.1", EcosystemId::Maven),
180///     Some("2.14.1")
181/// );
182/// ```
183pub fn concrete_pin_version(requirement: &str, ecosystem: EcosystemId) -> Option<&str> {
184    let trimmed = requirement.trim();
185    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("latest") {
186        return None;
187    }
188
189    let pinned = trimmed
190        .strip_prefix("==")
191        .or_else(|| trimmed.strip_prefix('='));
192    let bracket_pinned = trimmed
193        .strip_circumfix('[', ']')
194        .filter(|inner| !inner.contains(','));
195
196    match pinned.or(bracket_pinned) {
197        Some(body) => looks_like_a_single_version(body).then_some(body),
198        None => match bare_requirement_policy(ecosystem) {
199            BareRequirementPolicy::AlwaysRange => None,
200            BareRequirementPolicy::Concrete => {
201                looks_like_a_single_version(trimmed).then_some(trimmed)
202            }
203            BareRequirementPolicy::ConcreteIfFullVersion => {
204                is_full_semver_shape(trimmed).then_some(trimmed)
205            }
206        },
207    }
208}
209
210/// Returns `true` if `requirement` denotes a single concrete version. See
211/// [`concrete_pin_version`], whose boolean projection this is, for the
212/// acceptance rules. Test-only: production code needs the stripped text
213/// from `concrete_pin_version` itself, not just the boolean.
214#[cfg(test)]
215fn is_concrete_version(requirement: &str, ecosystem: EcosystemId) -> bool {
216    concrete_pin_version(requirement, ecosystem).is_some()
217}
218
219/// The version of `dep` this project treats as actually in use.
220///
221/// The lock-file-resolved version, else the declared requirement when it is
222/// already concrete ([`concrete_pin_version`]). `None` when neither applies.
223///
224/// A dependency whose manifest requirement is itself the resolved version
225/// ([`crate::lsp_helpers::RequirementResolution::manifest_requirement_is_resolved_version`] — a Go
226/// `require`-directive dependency) skips the lockfile step entirely, going
227/// straight to the declared requirement (go.sum is unreliable there — a
228/// checksum ledger that `go get`/`go build` only ever append to, so its
229/// last-occurrence-wins parse can surface a version still recorded in the
230/// file but no longer selected by Go's MVS). Shared by `deps-lsp`'s OSV
231/// target selection, its yanked-version check, and the yanked-diagnostic
232/// consistency check in [`super::diagnostics::generate_diagnostics_from_cache`]
233/// (#394 S1) — all three need "what version does the user actually have"
234/// for the same reason: querying a fabricated version produces a silent
235/// false negative.
236///
237/// # Examples
238///
239/// ```
240/// use deps_core::lsp_helpers::{
241///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
242///     RequirementResolution, SourcePolicy, in_use_version,
243/// };
244/// use deps_core::{ConcreteVersion, Dependency, EcosystemId, PackageName, VersionReq};
245/// use std::any::Any;
246/// use std::collections::HashMap;
247/// use tower_lsp_server::ls_types::Range;
248///
249/// struct SimpleDep {
250///     name: PackageName,
251///     version_req: Option<VersionReq>,
252/// }
253///
254/// impl Dependency for SimpleDep {
255///     fn name(&self) -> &PackageName {
256///         &self.name
257///     }
258///     fn name_range(&self) -> Range {
259///         Range::default()
260///     }
261///     fn version_requirement(&self) -> Option<&VersionReq> {
262///         self.version_req.as_ref()
263///     }
264///     fn version_range(&self) -> Option<Range> {
265///         None
266///     }
267///     fn source(&self) -> deps_core::parser::DependencySource {
268///         deps_core::parser::DependencySource::Registry
269///     }
270///     fn as_any(&self) -> &dyn Any {
271///         self
272///     }
273/// }
274///
275/// struct SimpleFormatter;
276/// impl PackageNaming for SimpleFormatter {}
277/// impl PackageRendering for SimpleFormatter {
278///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
279///         version.to_string()
280///     }
281///     fn package_url(&self, name: &PackageName) -> String {
282///         name.to_string()
283///     }
284/// }
285/// impl RequirementResolution for SimpleFormatter {}
286/// impl DiagnosticMessages for SimpleFormatter {}
287/// impl DiagnosticPolicy for SimpleFormatter {}
288/// impl SourcePolicy for SimpleFormatter {}
289/// impl OsvNaming for SimpleFormatter {}
290///
291/// let dep = SimpleDep {
292///     name: PackageName::new("time"),
293///     version_req: Some(VersionReq::new("=0.1.43")),
294/// };
295/// let resolved_versions: HashMap<PackageName, ConcreteVersion> = HashMap::new();
296///
297/// // No lock file, but the requirement is already an exact pin — falls
298/// // back to it, stripped of its `=` marker.
299/// assert_eq!(
300///     in_use_version(&dep, "time", &resolved_versions, &SimpleFormatter, EcosystemId::Cargo),
301///     Some("0.1.43".to_string())
302/// );
303/// ```
304pub fn in_use_version(
305    dep: &dyn Dependency,
306    normalized_name: &str,
307    resolved_versions: &HashMap<PackageName, ConcreteVersion>,
308    formatter: &dyn EcosystemFormatter,
309    ecosystem: EcosystemId,
310) -> Option<String> {
311    if formatter.manifest_requirement_is_resolved_version(dep) {
312        dep.version_requirement()
313            .and_then(|req| concrete_pin_version(req.as_str(), ecosystem))
314            .map(str::to_string)
315    } else {
316        resolved_versions
317            .get(normalized_name)
318            .or_else(|| resolved_versions.get(dep.name()))
319            .map(ConcreteVersion::to_string)
320            .or_else(|| {
321                dep.version_requirement()
322                    .and_then(|req| concrete_pin_version(req.as_str(), ecosystem))
323                    .map(str::to_string)
324            })
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn is_concrete_version_accepts_explicit_pins_in_any_ecosystem() {
334        for eco in [EcosystemId::Cargo, EcosystemId::Npm, EcosystemId::Go] {
335            assert!(is_concrete_version("=1.2.3", eco), "{eco:?}");
336        }
337        // Go's go.mod bare `v1.9.1` style: Go is not in the
338        // range-default set, so the bare form (with its `v` prefix) is
339        // accepted without needing an explicit `=`.
340        assert!(is_concrete_version("v1.9.1", EcosystemId::Go));
341    }
342
343    #[test]
344    fn is_concrete_version_pep440_double_equals_is_a_pin() {
345        // Critique C2: `strip_prefix('=')` alone turns PEP 440 `"==2.28.0"`
346        // into `"=2.28.0"`, whose first char then fails the digit check.
347        assert!(is_concrete_version("==2.28.0", EcosystemId::Pypi));
348    }
349
350    #[test]
351    fn is_concrete_version_bare_digit_accepted_for_non_range_default_ecosystems() {
352        // Maven/Go/Bundler/Dart/Gradle/NuGet: a bare version is already
353        // exact (or, for NuGet's PackageReference floor, resolves to
354        // exactly that version in practice). Gradle in particular has no
355        // implicit-caret default for a plain coordinate version like
356        // `"2.14.1"` — only the `+` dynamic-version suffix is a range,
357        // and that's rejected separately by `looks_like_a_single_version`.
358        for eco in [
359            EcosystemId::Maven,
360            EcosystemId::Go,
361            EcosystemId::Bundler,
362            EcosystemId::Dart,
363            EcosystemId::Gradle,
364            EcosystemId::NuGet,
365        ] {
366            assert!(is_concrete_version("2.14.1", eco), "{eco:?}");
367        }
368    }
369
370    #[test]
371    fn is_concrete_version_bare_digit_rejected_for_range_default_ecosystems() {
372        // Critique C2: Cargo's bare "1.2.3" is a caret range under
373        // Cargo's own default operator, not a pin — same for npm and
374        // Composer's implicit range notations. Deno reuses npm's exact
375        // grammar for both `jsr:` and `npm:` requirements, so it gets the
376        // same treatment (`bare_version_is_a_range`'s doc comment).
377        for eco in [
378            EcosystemId::Cargo,
379            EcosystemId::Npm,
380            EcosystemId::Composer,
381            EcosystemId::Deno,
382        ] {
383            assert!(!is_concrete_version("1.2.3", eco), "{eco:?}");
384            // ...but an explicit pin is still accepted.
385            assert!(is_concrete_version("=1.2.3", eco), "{eco:?}");
386        }
387    }
388
389    #[test]
390    fn is_concrete_version_rejects_partials_and_wildcards() {
391        // Critique C2: npm/Composer "1.x"/"1.2.x" and bare partials like
392        // "1.2" are ranges, and Gradle's "1.+" is a dynamic version —
393        // none of these contained a previously-rejected character.
394        for eco in [EcosystemId::Npm, EcosystemId::Composer] {
395            assert!(!is_concrete_version("1.x", eco), "{eco:?}");
396            assert!(!is_concrete_version("1.2.x", eco), "{eco:?}");
397            assert!(!is_concrete_version("1.2", eco), "{eco:?}");
398        }
399        assert!(!is_concrete_version("1.+", EcosystemId::Gradle));
400    }
401
402    #[test]
403    fn is_concrete_version_rejects_ranges_and_wildcards() {
404        for eco in [EcosystemId::Maven, EcosystemId::Go] {
405            assert!(!is_concrete_version("^1.0", eco));
406            assert!(!is_concrete_version("~1.2", eco));
407            assert!(!is_concrete_version("*", eco));
408            assert!(!is_concrete_version(">=1.0", eco));
409            assert!(!is_concrete_version(">=1.0 <2.0", eco));
410            assert!(!is_concrete_version("1.0.*", eco));
411            assert!(!is_concrete_version("", eco));
412        }
413    }
414
415    #[test]
416    fn is_concrete_version_rejects_non_version_schemes() {
417        let eco = EcosystemId::Go;
418        assert!(!is_concrete_version("latest", eco));
419        assert!(!is_concrete_version("github:user/repo", eco));
420        assert!(!is_concrete_version("file:../x", eco));
421        assert!(!is_concrete_version("main", eco));
422    }
423
424    #[test]
425    fn concrete_pin_version_strips_pep440_double_equals_comparator() {
426        // Regression guard: PyPI's parser retains the pep440 comparator
427        // in `version_requirement().as_str()` (`"==4.9.0"`, not
428        // `"4.9.0"` — confirmed by deps-pypi's `test_basic_pinned`). The
429        // verbatim string was silently unusable against real registry
430        // version strings in the yanked probe; `concrete_pin_version`
431        // must strip it.
432        assert_eq!(
433            concrete_pin_version("==4.9.0", EcosystemId::Pypi),
434            Some("4.9.0")
435        );
436    }
437
438    #[test]
439    fn concrete_pin_version_strips_single_equals_and_bracket_pins() {
440        assert_eq!(
441            concrete_pin_version("=1.2.3", EcosystemId::Cargo),
442            Some("1.2.3")
443        );
444        assert_eq!(
445            concrete_pin_version("[1.0.0]", EcosystemId::NuGet),
446            Some("1.0.0")
447        );
448    }
449
450    #[test]
451    fn concrete_pin_version_bare_version_returned_verbatim() {
452        // No operator to strip: Maven/Go/Bundler/Dart/Gradle/NuGet treat
453        // a bare version as already exact.
454        assert_eq!(
455            concrete_pin_version("2.14.1", EcosystemId::Maven),
456            Some("2.14.1")
457        );
458    }
459
460    #[test]
461    fn concrete_pin_version_rejects_ranges_and_partials() {
462        assert_eq!(concrete_pin_version("^1.0", EcosystemId::Cargo), None);
463        assert_eq!(concrete_pin_version("1.2.3", EcosystemId::Cargo), None);
464        assert_eq!(concrete_pin_version(">=1.0,<2.0", EcosystemId::Pypi), None);
465    }
466
467    // --- is_full_semver_shape ---
468
469    #[test]
470    fn is_full_semver_shape_accepts_full_versions_with_and_without_v_prefix() {
471        assert!(is_full_semver_shape("4.2.0"));
472        assert!(is_full_semver_shape("v4.2.0"));
473        assert!(is_full_semver_shape("V4.2.0"));
474    }
475
476    #[test]
477    fn is_full_semver_shape_accepts_prerelease_and_build_suffixes() {
478        assert!(is_full_semver_shape("v4.2.0-beta.1"));
479        assert!(is_full_semver_shape("4.2.0+build.5"));
480    }
481
482    #[test]
483    fn is_full_semver_shape_rejects_partial_versions() {
484        assert!(!is_full_semver_shape("v4"));
485        assert!(!is_full_semver_shape("v4.2"));
486    }
487
488    #[test]
489    fn is_full_semver_shape_rejects_non_version_and_sha_shapes() {
490        assert!(!is_full_semver_shape("main"));
491        assert!(!is_full_semver_shape(
492            "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
493        ));
494        assert!(!is_full_semver_shape(""));
495        assert!(!is_full_semver_shape("4.2.0.1"));
496        assert!(!is_full_semver_shape("4..0"));
497    }
498
499    // --- concrete_pin_version: BareRequirementPolicy::ConcreteIfFullVersion (GitHub Actions) ---
500
501    #[test]
502    fn concrete_pin_version_github_actions_full_bare_tag_is_concrete() {
503        assert_eq!(
504            concrete_pin_version("v4.2.0", EcosystemId::GithubActions),
505            Some("v4.2.0")
506        );
507    }
508
509    #[test]
510    fn concrete_pin_version_github_actions_moving_major_tag_is_a_range() {
511        // `v4` genuinely is a range (a moving major tag) — must not be queried as if
512        // it were the concrete version `4` (critique B2).
513        assert_eq!(concrete_pin_version("v4", EcosystemId::GithubActions), None);
514        assert_eq!(
515            concrete_pin_version("v4.2", EcosystemId::GithubActions),
516            None
517        );
518    }
519
520    #[test]
521    fn concrete_pin_version_github_actions_bare_sha_is_not_concrete() {
522        // A bare SHA has no dots, so it fails `is_full_semver_shape` and falls to the
523        // honest "unknown" `None` rather than being queried as a fabricated version.
524        assert_eq!(
525            concrete_pin_version(
526                "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
527                EcosystemId::GithubActions
528            ),
529            None
530        );
531    }
532
533    // --- concrete_pin_version: BareRequirementPolicy::ConcreteIfFullVersion (GitLab CI) ---
534
535    #[test]
536    fn concrete_pin_version_gitlab_ci_full_tag_is_concrete() {
537        assert_eq!(
538            concrete_pin_version("1.2.3", EcosystemId::GitlabCi),
539            Some("1.2.3")
540        );
541        assert_eq!(
542            concrete_pin_version("v1.2.3", EcosystemId::GitlabCi),
543            Some("v1.2.3")
544        );
545    }
546
547    #[test]
548    fn concrete_pin_version_gitlab_ci_partial_pin_is_a_range() {
549        // H2 regression (#466 review): a `component:` partial-semver pin (`1`, `1.2`)
550        // is a range under GitLab's own documented `~{raw}` semantics, not a single
551        // version — must not be queried as if it were the concrete version `1.2`.
552        assert_eq!(concrete_pin_version("1.2", EcosystemId::GitlabCi), None);
553        assert_eq!(concrete_pin_version("1", EcosystemId::GitlabCi), None);
554    }
555
556    #[test]
557    fn concrete_pin_version_gitlab_ci_digit_leading_sha_is_not_concrete() {
558        // A 40-hex SHA that happens to start with a digit must still fall to the
559        // honest "unknown" `None` rather than being misread as a version — it has no
560        // dots, so it fails `is_full_semver_shape` regardless of its leading
561        // character.
562        assert_eq!(
563            concrete_pin_version(
564                "1234567890abcdef1234567890abcdef12345678",
565                EcosystemId::GitlabCi
566            ),
567            None
568        );
569    }
570}