Skip to main content

provenant/parsers/
deno_lock.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9use serde_json::Value;
10use url::Url;
11
12use crate::models::{
13    DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest, Sha512Digest,
14};
15
16use super::PackageParser;
17use super::metadata::ParserMetadata;
18use super::utils::{
19    CappedIterExt, capped_iteration_limit, parse_sri, read_file_to_string, truncate_field,
20};
21
22const FIELD_VERSION: &str = "version";
23const FIELD_SPECIFIERS: &str = "specifiers";
24const FIELD_JSR: &str = "jsr";
25const FIELD_NPM: &str = "npm";
26const FIELD_REMOTE: &str = "remote";
27const FIELD_REDIRECTS: &str = "redirects";
28const FIELD_WORKSPACE: &str = "workspace";
29const FIELD_DEPENDENCIES: &str = "dependencies";
30
31pub struct DenoLockParser;
32
33impl PackageParser for DenoLockParser {
34    const PACKAGE_TYPE: PackageType = PackageType::Deno;
35
36    fn metadata() -> Vec<ParserMetadata> {
37        vec![ParserMetadata {
38            description: "Deno lockfile",
39            file_patterns: &["**/deno.lock"],
40            package_type: "deno",
41            primary_language: "TypeScript",
42            documentation_url: Some("https://docs.deno.com/runtime/fundamentals/modules/"),
43        }]
44    }
45
46    fn is_match(path: &Path) -> bool {
47        path.file_name().and_then(|name| name.to_str()) == Some("deno.lock")
48    }
49
50    fn extract_packages(path: &Path) -> Vec<PackageData> {
51        let content = match read_file_to_string(path, None) {
52            Ok(content) => content,
53            Err(e) => {
54                warn!("Failed to read deno.lock at {:?}: {}", path, e);
55                return vec![default_package_data()];
56            }
57        };
58
59        let json: Value = match serde_json::from_str(&content) {
60            Ok(json) => json,
61            Err(e) => {
62                warn!("Failed to parse deno.lock at {:?}: {}", path, e);
63                return vec![default_package_data()];
64            }
65        };
66
67        vec![parse_deno_lock(&json)]
68    }
69}
70
71fn parse_deno_lock(json: &Value) -> PackageData {
72    let lock_version = json.get(FIELD_VERSION).and_then(Value::as_str);
73
74    // deno.lock has gone through several incompatible layouts; dispatch on the declared
75    // version so older lockfiles still in the wild are not silently dropped:
76    //   * v1 (Deno 1.0-1.17): remote ESM URL imports only, no registry dependencies.
77    //   * v2/v3 (Deno 1.18-1.40): npm dependencies nested under npm.{specifiers,packages};
78    //     no jsr or workspace sections.
79    //   * v4 (Deno 1.45+) and v5 (current): flat top-level specifiers/npm/jsr/workspace.
80    // Unknown/newer versions fall back to the latest (flat) layout rather than dropping.
81    let (mut dependencies, workspace_direct) = match lock_version {
82        Some("1") => (Vec::new(), Vec::new()),
83        Some("2") | Some("3") => (extract_nested_npm_dependencies(json), Vec::new()),
84        Some("4") | Some("5") => extract_flat_dependencies(json),
85        other => {
86            warn!(
87                "Unrecognized deno.lock version {:?}; parsing with the latest known layout",
88                other
89            );
90            extract_flat_dependencies(json)
91        }
92    };
93    dependencies.extend(extract_redirect_dependencies(json));
94
95    let mut extra_data = HashMap::new();
96    if let Some(version) = lock_version {
97        extra_data.insert(
98            FIELD_VERSION.to_string(),
99            Value::String(version.to_string()),
100        );
101    }
102    if !workspace_direct.is_empty() {
103        extra_data.insert(
104            "workspace_dependencies".to_string(),
105            Value::Array(workspace_direct.into_iter().map(Value::String).collect()),
106        );
107    }
108
109    PackageData {
110        package_type: Some(DenoLockParser::PACKAGE_TYPE),
111        primary_language: Some("TypeScript".to_string()),
112        dependencies,
113        extra_data: (!extra_data.is_empty()).then_some(extra_data),
114        datasource_id: Some(DatasourceId::DenoLock),
115        ..Default::default()
116    }
117}
118
119/// Flat top-level layout used by deno.lock v4 and v5: `specifiers`, `npm`, `jsr`, and
120/// `workspace` sections. Returns the resolved dependencies plus the workspace's direct
121/// specifier list (recorded in extra_data by the caller).
122fn extract_flat_dependencies(json: &Value) -> (Vec<Dependency>, Vec<String>) {
123    let specifiers = json
124        .get(FIELD_SPECIFIERS)
125        .and_then(Value::as_object)
126        .cloned()
127        .unwrap_or_default();
128    let workspace_direct = extract_workspace_dependencies(json);
129
130    let mut dependencies = Vec::new();
131    let mut direct_jsr_keys = HashSet::new();
132    let mut direct_npm_keys = HashSet::new();
133
134    let workspace_limit =
135        capped_iteration_limit(workspace_direct.len(), "deno.lock workspace specifiers");
136    for specifier in workspace_direct.iter().take(workspace_limit) {
137        if let Some(resolved_key) = specifiers.get(specifier).and_then(Value::as_str) {
138            if specifier.starts_with("jsr:") {
139                if let Some(full_key) = resolve_jsr_full_key(specifier, resolved_key)
140                    && let Some(dep) =
141                        build_jsr_dependency(&full_key, true, &json[FIELD_JSR], Some(specifier))
142                {
143                    direct_jsr_keys.insert(full_key);
144                    dependencies.push(dep);
145                }
146            } else if specifier.starts_with("npm:")
147                && let Some(full_key) = resolve_npm_full_key(specifier, resolved_key)
148                && let Some(dep) =
149                    build_npm_dependency(&full_key, true, &json[FIELD_NPM], Some(specifier))
150            {
151                direct_npm_keys.insert(full_key);
152                dependencies.push(dep);
153            }
154        }
155    }
156
157    if let Some(jsr_map) = json.get(FIELD_JSR).and_then(Value::as_object) {
158        let jsr_limit = capped_iteration_limit(jsr_map.len(), "deno.lock jsr packages");
159        for key in jsr_map.keys().take(jsr_limit) {
160            if direct_jsr_keys.contains(key) {
161                continue;
162            }
163            if let Some(dep) = build_jsr_dependency(key, false, &json[FIELD_JSR], None) {
164                dependencies.push(dep);
165            }
166        }
167    }
168
169    if let Some(npm_map) = json.get(FIELD_NPM).and_then(Value::as_object) {
170        let npm_limit = capped_iteration_limit(npm_map.len(), "deno.lock npm packages");
171        for key in npm_map.keys().take(npm_limit) {
172            if direct_npm_keys.contains(key) {
173                continue;
174            }
175            if let Some(dep) = build_npm_dependency(key, false, &json[FIELD_NPM], None) {
176                dependencies.push(dep);
177            }
178        }
179    }
180
181    (dependencies, workspace_direct)
182}
183
184/// Nested layout used by deno.lock v2 and v3: npm dependencies live under `npm.packages`
185/// (the resolved graph), with `npm.specifiers` mapping requested ranges to resolved
186/// `name@version` keys. There are no jsr or workspace sections in these versions.
187fn extract_nested_npm_dependencies(json: &Value) -> Vec<Dependency> {
188    let Some(npm) = json.get(FIELD_NPM) else {
189        return Vec::new();
190    };
191    let Some(packages) = npm.get("packages") else {
192        return Vec::new();
193    };
194    let Some(packages_map) = packages.as_object() else {
195        return Vec::new();
196    };
197
198    // `npm.specifiers` maps each requested specifier (e.g. "chalk@5") to its resolved
199    // `name@version` key (e.g. "chalk@5.3.0"). Invert it so resolved packages referenced
200    // by a top-level specifier can be marked direct and keep their requested range; the
201    // remaining npm.packages entries are transitive.
202    let requested_by_resolved: HashMap<String, String> = npm
203        .get("specifiers")
204        .and_then(Value::as_object)
205        .map(|specifiers| {
206            specifiers
207                .iter()
208                .filter_map(|(specifier, resolved)| {
209                    resolved.as_str().map(|resolved| {
210                        (
211                            resolved.trim_start_matches("npm:").to_string(),
212                            specifier.clone(),
213                        )
214                    })
215                })
216                .collect()
217        })
218        .unwrap_or_default();
219
220    let mut dependencies = Vec::new();
221    let packages_limit =
222        capped_iteration_limit(packages_map.len(), "deno.lock nested npm packages");
223    for key in packages_map.keys().take(packages_limit) {
224        let requested = requested_by_resolved.get(key).map(String::as_str);
225        if let Some(dep) = build_npm_dependency(key, requested.is_some(), packages, requested) {
226            dependencies.push(dep);
227        }
228    }
229    dependencies
230}
231
232/// Module redirects, shared across all lockfile versions: each redirect target is a
233/// remote module whose integrity hash lives in the `remote` section.
234fn extract_redirect_dependencies(json: &Value) -> Vec<Dependency> {
235    let mut dependencies = Vec::new();
236    if let Some(redirects) = json.get(FIELD_REDIRECTS).and_then(Value::as_object) {
237        let redirects_limit = capped_iteration_limit(redirects.len(), "deno.lock redirects");
238        for (source, target) in redirects.iter().take(redirects_limit) {
239            let Some(target_url) = target.as_str() else {
240                continue;
241            };
242            let hash = json
243                .get(FIELD_REMOTE)
244                .and_then(Value::as_object)
245                .and_then(|remote| remote.get(target_url))
246                .and_then(Value::as_str)
247                .and_then(|value| Sha256Digest::from_hex(value).ok());
248
249            let name =
250                truncate_field(remote_name(target_url).unwrap_or_else(|| source.to_string()));
251            let purl = create_remote_purl(target_url).map(truncate_field);
252            let resolved_package = ResolvedPackage {
253                primary_language: Some("TypeScript".to_string()),
254                download_url: Some(truncate_field(target_url.to_string())),
255                sha1: None,
256                sha256: hash,
257                sha512: None,
258                md5: None,
259                is_virtual: true,
260                extra_data: Some(HashMap::from([(
261                    "redirect_source".to_string(),
262                    Value::String(truncate_field(source.to_string())),
263                )])),
264                dependencies: Vec::new(),
265                repository_homepage_url: None,
266                repository_download_url: None,
267                api_data_url: None,
268                datasource_id: Some(DatasourceId::DenoLock),
269                purl: purl.clone(),
270                ..ResolvedPackage::new(
271                    DenoLockParser::PACKAGE_TYPE,
272                    String::new(),
273                    name.clone(),
274                    String::new(),
275                )
276            };
277
278            // `redirects` maps any raw URL import to the target it actually resolved to;
279            // it does not distinguish a specifier written directly in the workspace's own
280            // source from one reached transitively through another remote module's own
281            // imports, so `is_runtime`/`is_optional`/`is_direct` are not provable here.
282            dependencies.push(Dependency {
283                purl,
284                extracted_requirement: Some(truncate_field(source.to_string())),
285                scope: Some("imports".to_string()),
286                is_runtime: None,
287                is_optional: None,
288                is_pinned: Some(true),
289                is_direct: None,
290                resolved_package: Some(Box::new(resolved_package)),
291                extra_data: None,
292            });
293        }
294    }
295    dependencies
296}
297
298fn extract_workspace_dependencies(json: &Value) -> Vec<String> {
299    json.get(FIELD_WORKSPACE)
300        .and_then(Value::as_object)
301        .and_then(|workspace| workspace.get(FIELD_DEPENDENCIES))
302        .and_then(Value::as_array)
303        .into_iter()
304        .flatten()
305        .filter_map(Value::as_str)
306        .map(|value| truncate_field(value.to_string()))
307        .collect()
308}
309
310fn build_jsr_dependency(
311    resolved_key: &str,
312    is_direct: bool,
313    jsr_section: &Value,
314    extracted_requirement: Option<&str>,
315) -> Option<Dependency> {
316    let jsr_entry = jsr_section.get(resolved_key)?;
317    let jsr_object = jsr_entry.as_object()?;
318    let (namespace, name, version) = parse_jsr_key(resolved_key)?;
319    let namespace = truncate_field(namespace);
320    let name = truncate_field(name);
321    let version_str = truncate_field(version.to_string());
322    let purl = create_generic_purl(
323        Some(&format!("jsr.io/{}", namespace)),
324        &name,
325        Some(&version_str),
326    )
327    .map(truncate_field);
328
329    // `is_direct` is provable from `workspace.dependencies` (the caller passes whether
330    // this key's specifier is in that list). deno.lock has no runtime-vs-dev or
331    // required-vs-optional distinction once a package.json dependency has been
332    // flattened into `specifiers`, so `is_runtime`/`is_optional` stay unset rather
333    // than guessed.
334    Some(Dependency {
335        purl: purl.clone(),
336        extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
337        scope: Some("imports".to_string()),
338        is_runtime: None,
339        is_optional: None,
340        is_pinned: Some(true),
341        is_direct: Some(is_direct),
342        resolved_package: Some(Box::new(ResolvedPackage {
343            primary_language: Some("TypeScript".to_string()),
344            download_url: None,
345            sha1: None,
346            sha256: jsr_object
347                .get("integrity")
348                .and_then(Value::as_str)
349                .and_then(|value| {
350                    parse_sri(value)
351                        .and_then(|(algo, hex)| {
352                            (algo == "sha256").then(|| Sha256Digest::from_hex(&hex).ok())
353                        })
354                        .flatten()
355                        .or_else(|| Sha256Digest::from_hex(value).ok())
356                }),
357            sha512: None,
358            md5: None,
359            is_virtual: true,
360            extra_data: None,
361            dependencies: extract_jsr_resolved_dependencies(jsr_object),
362            repository_homepage_url: None,
363            repository_download_url: None,
364            api_data_url: None,
365            datasource_id: Some(DatasourceId::DenoLock),
366            purl,
367            ..ResolvedPackage::new(DenoLockParser::PACKAGE_TYPE, namespace, name, version_str)
368        })),
369        extra_data: None,
370    })
371}
372
373/// A package's nested `dependencies` field is shaped differently across lockfile
374/// versions: v4/v5 `npm[key].dependencies` is an array of resolved `name@version`
375/// keys (`["ansi-styles@6.2.1"]`), while v2/v3 `npm.packages[key].dependencies` is an
376/// object mapping bare names to those resolved keys (`{"ansi-styles":"ansi-styles@6.2.1"}`).
377/// Both encode the same resolved keys, so collect them uniformly from either shape.
378fn npm_dependency_keys(value: Option<&Value>) -> Vec<String> {
379    match value {
380        Some(Value::Array(items)) => items
381            .iter()
382            .filter_map(Value::as_str)
383            .map(str::to_string)
384            .collect(),
385        Some(Value::Object(map)) => map
386            .values()
387            .filter_map(Value::as_str)
388            .map(str::to_string)
389            .collect(),
390        _ => Vec::new(),
391    }
392}
393
394fn build_npm_dependency(
395    resolved_key: &str,
396    is_direct: bool,
397    npm_section: &Value,
398    extracted_requirement: Option<&str>,
399) -> Option<Dependency> {
400    let npm_entry = npm_section.get(resolved_key)?;
401    let npm_object = npm_entry.as_object()?;
402    let (namespace, name, version) = parse_npm_key(resolved_key)?;
403    let namespace = namespace.map(truncate_field);
404    let name = truncate_field(name);
405    let version_str = truncate_field(version.to_string());
406    let purl = create_npm_purl(namespace.as_deref(), &name, Some(&version_str)).map(truncate_field);
407
408    let resolved_dependency_keys = npm_dependency_keys(npm_object.get(FIELD_DEPENDENCIES));
409    let resolved_dependency_limit = capped_iteration_limit(
410        resolved_dependency_keys.len(),
411        "deno.lock npm resolved dependencies",
412    );
413
414    // Same reasoning as `build_jsr_dependency`: `is_direct` comes from the caller's
415    // `workspace.dependencies` membership check, but deno.lock never records a
416    // runtime-vs-dev or required-vs-optional split, so `is_runtime`/`is_optional`
417    // stay unset.
418    Some(Dependency {
419        purl: purl.clone(),
420        extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
421        scope: Some("imports".to_string()),
422        is_runtime: None,
423        is_optional: None,
424        is_pinned: Some(true),
425        is_direct: Some(is_direct),
426        resolved_package: Some(Box::new(ResolvedPackage {
427            primary_language: Some("JavaScript".to_string()),
428            download_url: npm_object
429                .get("tarball")
430                .and_then(Value::as_str)
431                .map(|value| truncate_field(value.to_string())),
432            sha1: None,
433            sha256: None,
434            sha512: npm_object
435                .get("integrity")
436                .and_then(Value::as_str)
437                .and_then(|value| {
438                    parse_sri(value)
439                        .and_then(|(algo, hex)| {
440                            (algo == "sha512").then(|| Sha512Digest::from_hex(&hex).ok())
441                        })
442                        .flatten()
443                }),
444            md5: None,
445            is_virtual: true,
446            extra_data: None,
447            dependencies: resolved_dependency_keys
448                .into_iter()
449                .take(resolved_dependency_limit)
450                .filter_map(|value| {
451                    let (namespace, name, version) = parse_npm_key(&value)?;
452                    // This is a graph edge from an already-resolved npm package's own
453                    // `dependencies`, not the workspace's own specifier list, so none of
454                    // `is_runtime`/`is_optional`/`is_direct` is provable here.
455                    Some(Dependency {
456                        purl: create_npm_purl(namespace.as_deref(), &name, Some(version))
457                            .map(truncate_field),
458                        extracted_requirement: Some(truncate_field(value.clone())),
459                        scope: Some("dependencies".to_string()),
460                        is_runtime: None,
461                        is_optional: None,
462                        is_pinned: Some(true),
463                        is_direct: None,
464                        resolved_package: None,
465                        extra_data: None,
466                    })
467                })
468                .collect(),
469            repository_homepage_url: None,
470            repository_download_url: None,
471            api_data_url: None,
472            datasource_id: Some(DatasourceId::DenoLock),
473            purl,
474            ..ResolvedPackage::new(
475                PackageType::Npm,
476                namespace.unwrap_or_default(),
477                name,
478                version_str,
479            )
480        })),
481        extra_data: None,
482    })
483}
484
485fn extract_jsr_resolved_dependencies(
486    jsr_object: &serde_json::Map<String, Value>,
487) -> Vec<Dependency> {
488    jsr_object
489        .get(FIELD_DEPENDENCIES)
490        .and_then(Value::as_array)
491        .into_iter()
492        .flatten()
493        .filter_map(Value::as_str)
494        .capped("deno.lock jsr resolved dependencies")
495        .filter_map(|value| {
496            let (namespace, name, version) = parse_jsr_dependency_reference(value)?;
497            // A graph edge from an already-resolved jsr package's own `dependencies`
498            // array; not the workspace's own specifier list, so `is_runtime`,
499            // `is_optional`, and `is_direct` are not provable.
500            Some(Dependency {
501                purl: create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, version)
502                    .map(truncate_field),
503                extracted_requirement: Some(truncate_field(value.to_string())),
504                scope: Some("dependencies".to_string()),
505                is_runtime: None,
506                is_optional: None,
507                is_pinned: Some(version.is_some_and(is_exact_version)),
508                is_direct: None,
509                resolved_package: None,
510                extra_data: None,
511            })
512        })
513        .collect()
514}
515
516fn parse_jsr_key(key: &str) -> Option<(String, String, &str)> {
517    let scoped = key.strip_prefix('@')?;
518    let slash_index = scoped.find('/')?;
519    let namespace = format!("@{}", &scoped[..slash_index]);
520    let name_and_version = &scoped[slash_index + 1..];
521    let at_index = name_and_version.rfind('@')?;
522    let name = name_and_version[..at_index].to_string();
523    let version = &name_and_version[at_index + 1..];
524    Some((namespace, name, version))
525}
526
527fn parse_jsr_dependency_reference(value: &str) -> Option<(String, String, Option<&str>)> {
528    let rest = value.strip_prefix("jsr:")?;
529    let slash_index = rest.find('/')?;
530    let namespace = format!("@{}", &rest[1..slash_index]);
531    let name_and_version = &rest[slash_index + 1..];
532    let (name, version) = split_name_and_version(name_and_version);
533    Some((namespace, name.to_string(), version))
534}
535
536fn resolve_jsr_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
537    let (namespace, name, _) = parse_jsr_dependency_reference(specifier)?;
538    Some(format!("{}/{}@{}", namespace, name, resolved_version))
539}
540
541fn parse_npm_key(key: &str) -> Option<(Option<String>, String, &str)> {
542    if let Some(scoped) = key.strip_prefix('@') {
543        let slash_index = scoped.find('/')?;
544        let namespace = format!("@{}", &scoped[..slash_index]);
545        let name_and_version = &scoped[slash_index + 1..];
546        let at_index = name_and_version.rfind('@')?;
547        let name = name_and_version[..at_index].to_string();
548        let version = &name_and_version[at_index + 1..];
549        Some((Some(namespace), name, version))
550    } else {
551        let at_index = key.rfind('@')?;
552        let name = key[..at_index].to_string();
553        let version = &key[at_index + 1..];
554        Some((None, name, version))
555    }
556}
557
558fn resolve_npm_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
559    let (namespace, name, _) = parse_npm_specifier(specifier)?;
560    Some(match namespace {
561        Some(namespace) => format!("{}/{}@{}", namespace, name, resolved_version),
562        None => format!("{}@{}", name, resolved_version),
563    })
564}
565
566fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
567    let rest = specifier.strip_prefix("npm:")?;
568    let (name_part, version) = split_name_and_version(rest);
569    if let Some(scoped) = name_part.strip_prefix('@') {
570        let slash_index = scoped.find('/')?;
571        let namespace = format!("@{}", &scoped[..slash_index]);
572        let name = scoped[slash_index + 1..].to_string();
573        Some((Some(namespace), name, version))
574    } else {
575        Some((None, name_part.to_string(), version))
576    }
577}
578
579fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
580    if let Some(index) = input.rfind('@') {
581        let (name, version) = input.split_at(index);
582        if !name.is_empty() {
583            return (name, Some(&version[1..]));
584        }
585    }
586    (input, None)
587}
588
589fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
590    let mut purl = PackageUrl::new("npm", name).ok()?;
591    if let Some(namespace) = namespace {
592        purl.with_namespace(namespace).ok()?;
593    }
594    if let Some(version) = version {
595        purl.with_version(version).ok()?;
596    }
597    Some(purl.to_string())
598}
599
600fn create_generic_purl(
601    namespace: Option<&str>,
602    name: &str,
603    version: Option<&str>,
604) -> Option<String> {
605    let mut purl = PackageUrl::new("generic", name).ok()?;
606    if let Some(namespace) = namespace {
607        purl.with_namespace(namespace).ok()?;
608    }
609    if let Some(version) = version {
610        purl.with_version(version).ok()?;
611    }
612    Some(purl.to_string())
613}
614
615fn create_remote_purl(specifier: &str) -> Option<String> {
616    let url = Url::parse(specifier).ok()?;
617    let segments: Vec<&str> = url.path_segments()?.collect();
618    let name = segments.last()?.to_string();
619    let namespace = if segments.len() > 1 {
620        Some(format!(
621            "{}/{}",
622            url.host_str()?,
623            segments[..segments.len() - 1].join("/")
624        ))
625    } else {
626        url.host_str().map(|host| host.to_string())
627    };
628    create_generic_purl(namespace.as_deref(), &name, None)
629}
630
631fn remote_name(url: &str) -> Option<String> {
632    let url = Url::parse(url).ok()?;
633    url.path_segments()?
634        .next_back()
635        .map(|value| value.to_string())
636}
637
638fn is_exact_version(version: &str) -> bool {
639    !version.contains('^')
640        && !version.contains('~')
641        && !version.contains('*')
642        && !version.contains('>')
643        && !version.contains('<')
644        && !version.contains('|')
645        && !version.contains(' ')
646}
647
648fn default_package_data() -> PackageData {
649    PackageData {
650        package_type: Some(DenoLockParser::PACKAGE_TYPE),
651        primary_language: Some("TypeScript".to_string()),
652        datasource_id: Some(DatasourceId::DenoLock),
653        ..Default::default()
654    }
655}