Skip to main content

provenant/parsers/
yarn_lock.rs

1//! Parser for Yarn yarn.lock lockfiles.
2//!
3//! Extracts resolved dependency information from Yarn lockfiles supporting both
4//! Yarn Classic (v1) and Yarn Berry (v2+) formats with different syntax and structures.
5//!
6//! # Supported Formats
7//! - yarn.lock (Classic v1 format - key-value style)
8//! - yarn.lock (Berry v2+ format - YAML-like structure with different key format)
9//!
10//! # Key Features
11//! - Multi-format support for Yarn Classic and Berry versions
12//! - Direct vs transitive dependency tracking (`is_direct`)
13//! - Integrity hash extraction (sha1, sha512, sha256)
14//! - Package URL (purl) generation for scoped and unscoped packages
15//! - Workspace and monorepo dependency resolution
16//!
17//! # Implementation Notes
18//! - v1 format: `"@scope/name@version"` keys with nested `version` and `integrity` fields
19//! - v2+ format: Similar structure but different key generation with workspace awareness
20//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
21//! - Graceful error handling with `warn!()` logs
22
23use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
24use crate::parsers::utils::{npm_purl, parse_sri};
25use log::warn;
26use serde_json::Value as JsonValue;
27use serde_yaml::Value;
28use std::collections::{HashMap, HashSet};
29use std::fs;
30use std::path::Path;
31
32use super::PackageParser;
33
34/// Yarn lockfile parser supporting both Yarn Classic (v1) and Berry (v2+) formats.
35///
36/// Extracts pinned dependency versions with integrity hashes from yarn.lock files.
37pub struct YarnLockParser;
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40struct ManifestDependencyInfo {
41    scope: &'static str,
42    is_runtime: bool,
43    is_optional: bool,
44}
45
46impl PackageParser for YarnLockParser {
47    const PACKAGE_TYPE: PackageType = PackageType::Npm;
48
49    fn is_match(path: &Path) -> bool {
50        path.file_name()
51            .and_then(|name| name.to_str())
52            .map(|name| name == "yarn.lock")
53            .unwrap_or(false)
54    }
55
56    fn extract_packages(path: &Path) -> Vec<PackageData> {
57        let content = match fs::read_to_string(path) {
58            Ok(content) => content,
59            Err(e) => {
60                warn!("Failed to read yarn.lock at {:?}: {}", path, e);
61                return vec![default_package_data()];
62            }
63        };
64
65        let is_v2 = detect_yarn_version(&content);
66        let manifest_dependencies = load_manifest_dependency_info(path);
67
68        vec![if is_v2 {
69            parse_yarn_v2(&content, &manifest_dependencies)
70        } else {
71            parse_yarn_v1(&content, &manifest_dependencies)
72        }]
73    }
74}
75
76/// Detect if yarn.lock is v2 (has __metadata) or v1 (has "yarn lockfile v1")
77pub fn detect_yarn_version(content: &str) -> bool {
78    content
79        .lines()
80        .take(10)
81        .any(|line| line.contains("__metadata:"))
82}
83
84/// Parse yarn.lock v2 format (standard YAML)
85fn parse_yarn_v2(
86    content: &str,
87    manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
88) -> PackageData {
89    let yaml_value: Value = match serde_yaml::from_str(content) {
90        Ok(val) => val,
91        Err(e) => {
92            warn!("Failed to parse yarn.lock v2 YAML: {}", e);
93            return default_package_data();
94        }
95    };
96
97    let yaml_map = match yaml_value.as_mapping() {
98        Some(map) => map,
99        None => return default_package_data(),
100    };
101
102    let mut dependencies = Vec::new();
103
104    for (spec, details) in yaml_map {
105        if spec.as_str().map(|s| s == "__metadata").unwrap_or(false) {
106            continue;
107        }
108
109        let _spec_str = match spec.as_str() {
110            Some(s) => s,
111            None => continue,
112        };
113
114        let details_map = match details.as_mapping() {
115            Some(map) => map,
116            None => continue,
117        };
118
119        let _version = extract_yaml_string(details_map, "version").unwrap_or_default();
120        let resolution = extract_yaml_string(details_map, "resolution").unwrap_or_default();
121
122        let (namespace_opt, name, resolved_version) = parse_yarn_v2_resolution(&resolution);
123        let namespace = namespace_opt.unwrap_or_default();
124        let full_name = full_package_name(&namespace, &name);
125        let manifest_info = manifest_dependencies.get(&full_name);
126        let purl = create_purl(&namespace, &name, &resolved_version);
127        let checksum = extract_yaml_string(details_map, "checksum");
128
129        let deps_yaml = details_map.get("dependencies");
130        let peer_deps_yaml = details_map.get("peerDependencies");
131
132        let nested_deps = parse_yaml_dependencies(deps_yaml);
133        let peer_deps = parse_yaml_dependencies(peer_deps_yaml);
134
135        let all_deps = if peer_deps.is_empty() {
136            nested_deps
137        } else {
138            let mut combined = nested_deps;
139            for mut dep in peer_deps {
140                dep.scope = Some("peerDependencies".to_string());
141                dep.is_optional = Some(true);
142                dep.is_runtime = Some(false);
143                combined.push(dep);
144            }
145            combined
146        };
147
148        let resolved_package = ResolvedPackage {
149            package_type: YarnLockParser::PACKAGE_TYPE,
150            namespace: namespace.clone(),
151            name: name.clone(),
152            version: resolved_version.clone(),
153            primary_language: Some("JavaScript".to_string()),
154            download_url: None,
155            sha1: None,
156            sha256: None,
157            sha512: checksum,
158            md5: None,
159            is_virtual: true,
160            extra_data: None,
161            dependencies: all_deps,
162            repository_homepage_url: None,
163            repository_download_url: None,
164            api_data_url: None,
165            datasource_id: Some(DatasourceId::YarnLock),
166            purl: None,
167        };
168
169        let (scope, is_runtime, is_optional, is_direct) = manifest_info
170            .map(|info| {
171                (
172                    info.scope.to_string(),
173                    info.is_runtime,
174                    info.is_optional,
175                    true,
176                )
177            })
178            .unwrap_or_else(|| {
179                (
180                    "dependencies".to_string(),
181                    true,
182                    false,
183                    resolution.contains("workspace:"),
184                )
185            });
186
187        let dependency = Dependency {
188            purl,
189            extracted_requirement: Some(resolved_version),
190            scope: Some(scope),
191            is_runtime: Some(is_runtime),
192            is_optional: Some(is_optional),
193            is_pinned: Some(true),
194            is_direct: Some(is_direct),
195            resolved_package: Some(Box::new(resolved_package)),
196            extra_data: None,
197        };
198
199        dependencies.push(dependency);
200    }
201
202    PackageData {
203        package_type: Some(YarnLockParser::PACKAGE_TYPE),
204        namespace: None,
205        name: None,
206        version: None,
207        qualifiers: None,
208        subpath: None,
209        primary_language: None,
210        description: None,
211        release_date: None,
212        parties: Vec::new(),
213        keywords: Vec::new(),
214        homepage_url: None,
215        download_url: None,
216        size: None,
217        sha1: None,
218        md5: None,
219        sha256: None,
220        sha512: None,
221        bug_tracking_url: None,
222        code_view_url: None,
223        vcs_url: None,
224        copyright: None,
225        holder: None,
226        declared_license_expression: None,
227        declared_license_expression_spdx: None,
228        license_detections: Vec::new(),
229        other_license_expression: None,
230        other_license_expression_spdx: None,
231        other_license_detections: Vec::new(),
232        extracted_license_statement: None,
233        notice_text: None,
234        source_packages: Vec::new(),
235        file_references: Vec::new(),
236        is_private: false,
237        is_virtual: false,
238        extra_data: None,
239        dependencies,
240        repository_homepage_url: None,
241        repository_download_url: None,
242        api_data_url: None,
243        datasource_id: Some(DatasourceId::YarnLock),
244        purl: None,
245    }
246}
247
248/// Parse yarn.lock v1 format (custom YAML-like)
249fn parse_yarn_v1(
250    content: &str,
251    manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
252) -> PackageData {
253    let mut dependencies = Vec::new();
254    let mut seen_purls = HashSet::new();
255
256    for block in content.split("\n\n") {
257        if is_empty_or_comment_block(block) {
258            continue;
259        }
260
261        if let Some(dep) = parse_yarn_v1_block(block, manifest_dependencies) {
262            if let Some(ref purl) = dep.purl {
263                if seen_purls.insert(purl.clone()) {
264                    dependencies.push(dep);
265                }
266            } else {
267                dependencies.push(dep);
268            }
269        }
270    }
271
272    PackageData {
273        package_type: Some(YarnLockParser::PACKAGE_TYPE),
274        namespace: None,
275        name: None,
276        version: None,
277        qualifiers: None,
278        subpath: None,
279        primary_language: None,
280        description: None,
281        release_date: None,
282        parties: Vec::new(),
283        keywords: Vec::new(),
284        homepage_url: None,
285        download_url: None,
286        size: None,
287        sha1: None,
288        md5: None,
289        sha256: None,
290        sha512: None,
291        bug_tracking_url: None,
292        code_view_url: None,
293        vcs_url: None,
294        copyright: None,
295        holder: None,
296        declared_license_expression: None,
297        declared_license_expression_spdx: None,
298        license_detections: Vec::new(),
299        other_license_expression: None,
300        other_license_expression_spdx: None,
301        other_license_detections: Vec::new(),
302        extracted_license_statement: None,
303        notice_text: None,
304        source_packages: Vec::new(),
305        file_references: Vec::new(),
306        is_private: false,
307        is_virtual: false,
308        extra_data: None,
309        dependencies,
310        repository_homepage_url: None,
311        repository_download_url: None,
312        api_data_url: None,
313        datasource_id: Some(DatasourceId::YarnLock),
314        purl: None,
315    }
316}
317
318fn is_empty_or_comment_block(block: &str) -> bool {
319    block
320        .lines()
321        .all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
322}
323
324/// Parse integrity field (format: "sha512-base64string==")
325fn parse_integrity_field(integrity: &str) -> Option<String> {
326    parse_sri(integrity).and_then(|(algo, hex_digest)| {
327        if algo == "sha512" {
328            Some(hex_digest)
329        } else {
330            None
331        }
332    })
333}
334
335/// Extract namespace and name from package name ("@types/node" -> ("@types", "node"))
336pub fn extract_namespace_and_name(package_name: &str) -> (String, String) {
337    if package_name.starts_with('@') {
338        let parts: Vec<&str> = package_name.splitn(2, '/').collect();
339        if parts.len() == 2 {
340            (parts[0].to_string(), parts[1].to_string())
341        } else {
342            (String::new(), package_name.to_string())
343        }
344    } else {
345        (String::new(), package_name.to_string())
346    }
347}
348
349fn create_purl(namespace: &str, name: &str, version: &str) -> Option<String> {
350    let full_name = if namespace.is_empty() {
351        name.to_string()
352    } else {
353        format!("{}/{}", namespace, name)
354    };
355    let version_opt = if version.is_empty() {
356        None
357    } else {
358        Some(version)
359    };
360    npm_purl(&full_name, version_opt)
361}
362
363fn default_package_data() -> PackageData {
364    PackageData {
365        package_type: Some(YarnLockParser::PACKAGE_TYPE),
366        datasource_id: Some(DatasourceId::YarnLock),
367        ..Default::default()
368    }
369}
370
371/// Parse a single yarn v1 dependency block
372fn parse_yarn_v1_block(
373    block: &str,
374    manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
375) -> Option<Dependency> {
376    let lines: Vec<&str> = block.lines().collect();
377    if lines.is_empty() {
378        return None;
379    }
380
381    let requirement_line = lines[0]
382        .trim()
383        .strip_suffix(':')
384        .unwrap_or_else(|| lines[0].trim())
385        .trim_matches('"');
386    if requirement_line.is_empty() || requirement_line.starts_with('#') {
387        return None;
388    }
389
390    let (namespace, name, constraint) = parse_yarn_v1_requirement(requirement_line);
391
392    if name.is_empty() {
393        return None;
394    }
395
396    let mut version = String::new();
397    let mut resolved_url = String::new();
398    let mut integrity = String::new();
399    let mut nested_deps = Vec::new();
400
401    for line in &lines[1..] {
402        let trimmed = line.trim();
403        if trimmed.is_empty() {
404            continue;
405        }
406
407        if trimmed.starts_with("version") {
408            version = extract_quoted_value(trimmed).unwrap_or_default();
409        } else if trimmed.starts_with("resolved") {
410            resolved_url = extract_quoted_value(trimmed).unwrap_or_default();
411        } else if trimmed.starts_with("integrity") {
412            integrity = trimmed
413                .strip_prefix("integrity")
414                .map(|s| s.trim().to_string())
415                .unwrap_or_default();
416        } else if trimmed.starts_with("dependencies") {
417            // Dependencies block - parse indented lines
418            continue;
419        } else if trimmed.starts_with("  ") && !trimmed.starts_with("    ") {
420            // Dependency line (2-space indent)
421            let dep_line = trimmed.trim();
422            if let Some(dep) = parse_yarn_v1_dependency_line(dep_line, &namespace, &name, &version)
423            {
424                nested_deps.push(dep);
425            }
426        }
427    }
428
429    let sha512 = if integrity.is_empty() {
430        None
431    } else {
432        parse_integrity_field(&integrity)
433    };
434
435    let full_name = full_package_name(&namespace, &name);
436    let manifest_info = manifest_dependencies.get(&full_name);
437    let purl = create_purl(&namespace, &name, &version);
438    let (scope, is_runtime, is_optional, is_direct) = manifest_info
439        .map(|info| {
440            (
441                info.scope.to_string(),
442                info.is_runtime,
443                info.is_optional,
444                true,
445            )
446        })
447        .unwrap_or_else(|| ("dependencies".to_string(), true, false, false));
448
449    let resolved_package = ResolvedPackage {
450        package_type: YarnLockParser::PACKAGE_TYPE,
451        namespace: namespace.clone(),
452        name: name.clone(),
453        version: version.clone(),
454        primary_language: Some("JavaScript".to_string()),
455        download_url: if resolved_url.is_empty() {
456            None
457        } else {
458            Some(resolved_url)
459        },
460        sha1: None,
461        sha256: None,
462        sha512,
463        md5: None,
464        is_virtual: true,
465        extra_data: None,
466        dependencies: nested_deps,
467        repository_homepage_url: None,
468        repository_download_url: None,
469        api_data_url: None,
470        datasource_id: Some(DatasourceId::YarnLock),
471        purl: None,
472    };
473
474    Some(Dependency {
475        purl,
476        extracted_requirement: Some(constraint),
477        scope: Some(scope),
478        is_runtime: Some(is_runtime),
479        is_optional: Some(is_optional),
480        is_pinned: Some(true),
481        is_direct: Some(is_direct),
482        resolved_package: Some(Box::new(resolved_package)),
483        extra_data: None,
484    })
485}
486
487fn full_package_name(namespace: &str, name: &str) -> String {
488    if namespace.is_empty() {
489        name.to_string()
490    } else {
491        format!("{namespace}/{name}")
492    }
493}
494
495fn load_manifest_dependency_info(path: &Path) -> HashMap<String, ManifestDependencyInfo> {
496    let Some(parent) = path.parent() else {
497        return HashMap::new();
498    };
499
500    let manifest_path = parent.join("package.json");
501    let Ok(content) = fs::read_to_string(manifest_path) else {
502        return HashMap::new();
503    };
504
505    let Ok(json) = serde_json::from_str::<JsonValue>(&content) else {
506        return HashMap::new();
507    };
508
509    let peer_optional = json
510        .get("peerDependenciesMeta")
511        .and_then(|value| value.as_object())
512        .map(|meta| {
513            meta.iter()
514                .filter_map(|(name, value)| {
515                    value
516                        .as_object()
517                        .and_then(|entry| entry.get("optional"))
518                        .and_then(|value| value.as_bool())
519                        .map(|optional| (name.clone(), optional))
520                })
521                .collect::<HashMap<_, _>>()
522        })
523        .unwrap_or_default();
524
525    let mut dependencies = HashMap::new();
526    insert_manifest_dependency_info(
527        &mut dependencies,
528        &json,
529        "dependencies",
530        ManifestDependencyInfo {
531            scope: "dependencies",
532            is_runtime: true,
533            is_optional: false,
534        },
535    );
536    insert_manifest_dependency_info(
537        &mut dependencies,
538        &json,
539        "devDependencies",
540        ManifestDependencyInfo {
541            scope: "devDependencies",
542            is_runtime: false,
543            is_optional: true,
544        },
545    );
546    insert_manifest_dependency_info(
547        &mut dependencies,
548        &json,
549        "optionalDependencies",
550        ManifestDependencyInfo {
551            scope: "optionalDependencies",
552            is_runtime: true,
553            is_optional: true,
554        },
555    );
556
557    if let Some(peer_dependencies) = json
558        .get("peerDependencies")
559        .and_then(|value| value.as_object())
560    {
561        for name in peer_dependencies.keys() {
562            dependencies.insert(
563                name.clone(),
564                ManifestDependencyInfo {
565                    scope: "peerDependencies",
566                    is_runtime: false,
567                    is_optional: peer_optional.get(name).copied().unwrap_or(false),
568                },
569            );
570        }
571    }
572
573    dependencies
574}
575
576fn insert_manifest_dependency_info(
577    dependencies: &mut HashMap<String, ManifestDependencyInfo>,
578    json: &JsonValue,
579    field: &str,
580    info: ManifestDependencyInfo,
581) {
582    if let Some(entries) = json.get(field).and_then(|value| value.as_object()) {
583        for name in entries.keys() {
584            dependencies.insert(name.clone(), info.clone());
585        }
586    }
587}
588
589/// Parse yarn v1 requirement line: "express@^4.0.0" or "@babel/core@^7.1.0"
590pub fn parse_yarn_v1_requirement(line: &str) -> (String, String, String) {
591    // Handle multiple aliases: "rimraf@2, rimraf@~2.5.1"
592    if line.contains(", ") {
593        // Use the first part for parsing
594        let first_part = line.split(", ").next().unwrap_or(line);
595        return parse_single_yarn_v1_requirement(first_part);
596    }
597    parse_single_yarn_v1_requirement(line)
598}
599
600/// Parse a single yarn v1 requirement
601fn parse_single_yarn_v1_requirement(line: &str) -> (String, String, String) {
602    if let Some(at_pos) = line.rfind('@') {
603        let name_part = &line[..at_pos];
604        let constraint = &line[at_pos + 1..];
605        let (namespace, name) = extract_namespace_and_name(name_part);
606
607        if !name.is_empty() {
608            return (namespace, name, constraint.to_string());
609        }
610    }
611
612    (String::new(), String::new(), String::new())
613}
614
615/// Parse a dependency line from yarn v1 block: "\"dep@^1.0.0\""
616fn parse_yarn_v1_dependency_line(
617    line: &str,
618    _parent_namespace: &str,
619    _parent_name: &str,
620    parent_version: &str,
621) -> Option<Dependency> {
622    let trimmed = line.trim_matches('"');
623    if !trimmed.contains('@') {
624        return None;
625    }
626
627    let (namespace, name, constraint) = parse_yarn_v1_requirement(trimmed);
628
629    let purl = create_purl(&namespace, &name, parent_version);
630
631    Some(Dependency {
632        purl,
633        extracted_requirement: Some(constraint),
634        scope: Some("dependencies".to_string()),
635        is_runtime: Some(true),
636        is_optional: Some(false),
637        is_pinned: Some(false),
638        is_direct: Some(false),
639        resolved_package: None,
640        extra_data: None,
641    })
642}
643
644/// Extract value from quoted line: 'version "1.0.0"' -> "1.0.0"
645fn extract_quoted_value(line: &str) -> Option<String> {
646    line.find('"').and_then(|start| {
647        let rest = &line[start + 1..];
648        rest.find('"').map(|end| rest[..end].to_string())
649    })
650}
651
652/// Parse yarn v2 resolution: "@actions/core@npm:1.2.6" -> ("@actions", "core", "1.2.6")
653pub fn parse_yarn_v2_resolution(resolution: &str) -> (Option<String>, String, String) {
654    if resolution.contains("@npm:") {
655        let parts: Vec<&str> = resolution.split("@npm:").collect();
656        if parts.len() == 2 {
657            let package_name = parts[0];
658            let version = parts[1];
659            let (namespace, name) = extract_namespace_and_name(package_name);
660            let namespace_opt = if namespace.is_empty() {
661                None
662            } else {
663                Some(namespace)
664            };
665            return (namespace_opt, name, version.to_string());
666        }
667    }
668
669    let (namespace, name) = extract_namespace_and_name(resolution);
670    let namespace_opt = if namespace.is_empty() {
671        None
672    } else {
673        Some(namespace)
674    };
675    (namespace_opt, name, "*".to_string())
676}
677
678/// Extract string value from YAML mapping
679fn extract_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option<String> {
680    map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
681}
682
683/// Parse dependencies from YAML Value
684fn parse_yaml_dependencies(yaml_value: Option<&Value>) -> Vec<Dependency> {
685    let mut dependencies = Vec::new();
686
687    if let Some(deps_value) = yaml_value
688        && let Some(mapping) = deps_value.as_mapping()
689    {
690        for (key, value) in mapping {
691            let name = match key.as_str() {
692                Some(s) => s.to_string(),
693                None => continue,
694            };
695
696            let constraint = match value.as_str() {
697                Some(s) => s.to_string(),
698                None => "*".to_string(),
699            };
700
701            let (namespace, dep_name) = extract_namespace_and_name(&name);
702            let purl = create_purl(&namespace, &dep_name, &constraint);
703
704            dependencies.push(Dependency {
705                purl,
706                extracted_requirement: Some(constraint),
707                scope: Some("dependencies".to_string()),
708                is_runtime: Some(true),
709                is_optional: Some(false),
710                is_pinned: Some(false),
711                is_direct: Some(false),
712                resolved_package: None,
713                extra_data: None,
714            });
715        }
716    }
717    dependencies
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use std::path::PathBuf;
724
725    #[test]
726    fn test_is_match_yarn_lock() {
727        let valid_path = PathBuf::from("/some/path/yarn.lock");
728        assert!(YarnLockParser::is_match(&valid_path));
729    }
730
731    #[test]
732    fn test_is_not_match_package_json() {
733        let invalid_path = PathBuf::from("/some/path/package.json");
734        assert!(!YarnLockParser::is_match(&invalid_path));
735    }
736
737    #[test]
738    fn test_detect_yarn_v2() {
739        let content = r#"# This file is generated by running "yarn install"
740__metadata:
741  version: 6
742"#;
743        assert!(detect_yarn_version(content));
744    }
745
746    #[test]
747    fn test_detect_yarn_v1() {
748        let content = r#"# THIS IS AN AUTOGENERATED FILE
749# yarn lockfile v1
750
751abbrev@1:
752  version "1.0.9"
753"#;
754        assert!(!detect_yarn_version(content));
755    }
756
757    #[test]
758    fn test_extract_namespace_and_name_scoped() {
759        let (namespace, name) = extract_namespace_and_name("@types/node");
760        assert_eq!(namespace, "@types");
761        assert_eq!(name, "node");
762    }
763
764    #[test]
765    fn test_extract_namespace_and_name_regular() {
766        let (namespace, name) = extract_namespace_and_name("express");
767        assert_eq!(namespace, "");
768        assert_eq!(name, "express");
769    }
770
771    #[test]
772    fn test_parse_yarn_v1_requirement() {
773        let (namespace, name, constraint) = parse_yarn_v1_requirement("express@^4.0.0");
774        assert_eq!(namespace, "");
775        assert_eq!(name, "express");
776        assert_eq!(constraint, "^4.0.0");
777    }
778
779    #[test]
780    fn test_parse_yarn_v1_requirement_scoped() {
781        let (namespace, name, constraint) = parse_yarn_v1_requirement("@types/node@^18.0.0");
782        assert_eq!(namespace, "@types");
783        assert_eq!(name, "node");
784        assert_eq!(constraint, "^18.0.0");
785    }
786
787    #[test]
788    fn test_parse_yarn_v2_resolution() {
789        let (namespace, name, version) = parse_yarn_v2_resolution("@actions/core@npm:1.2.6");
790        assert_eq!(namespace, Some("@actions".to_string()));
791        assert_eq!(name, "core");
792        assert_eq!(version, "1.2.6");
793    }
794}
795
796crate::register_parser!(
797    "yarn.lock lockfile (v1 and v2+)",
798    &["**/yarn.lock"],
799    "npm",
800    "JavaScript",
801    Some("https://classic.yarnpkg.com/lang/en/docs/yarn-lock/"),
802);