Skip to main content

provenant/parsers/
npm_lock.rs

1//! Parser for npm package-lock.json and npm-shrinkwrap.json lockfiles.
2//!
3//! Extracts resolved dependency information including exact versions, integrity hashes,
4//! and dependency trees from npm lockfile formats (v1, v2, v3).
5//!
6//! # Supported Formats
7//! - package-lock.json (lockfile v1, v2, v3)
8//! - npm-shrinkwrap.json
9//!
10//! # Key Features
11//! - Lockfile version detection (v1, v2, v3)
12//! - Direct vs transitive dependency tracking (`is_direct`)
13//! - Integrity hash extraction (sha512, sha256, sha1, md5)
14//! - Package URL (purl) generation
15//! - Dependency graph traversal with proper nesting
16//!
17//! # Implementation Notes
18//! - v1: Dependencies nested in `dependencies` objects
19//! - v2+: Flat dependency structure with `node_modules/` prefix for nesting
20//! - Direct dependencies determined by top-level `dependencies` and `devDependencies`
21
22use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
23use crate::parsers::utils::{npm_purl, parse_sri};
24use log::warn;
25use serde_json::Value;
26use std::collections::HashMap;
27use std::fs;
28use std::path::Path;
29
30use super::PackageParser;
31
32// Field name constants
33const FIELD_LOCKFILE_VERSION: &str = "lockfileVersion";
34const FIELD_NAME: &str = "name";
35const FIELD_VERSION: &str = "version";
36const FIELD_DEPENDENCIES: &str = "dependencies";
37const FIELD_PACKAGES: &str = "packages";
38const FIELD_RESOLVED: &str = "resolved";
39const FIELD_INTEGRITY: &str = "integrity";
40const FIELD_DEV: &str = "dev";
41const FIELD_OPTIONAL: &str = "optional";
42const FIELD_DEV_OPTIONAL: &str = "devOptional";
43const FIELD_LINK: &str = "link";
44
45/// npm lockfile parser supporting package-lock.json v1, v2, and v3 formats.
46///
47/// Extracts pinned dependency versions with integrity hashes from lockfiles
48/// including npm-shrinkwrap.json variants.
49pub struct NpmLockParser;
50
51impl PackageParser for NpmLockParser {
52    const PACKAGE_TYPE: PackageType = PackageType::Npm;
53
54    fn is_match(path: &Path) -> bool {
55        path.file_name()
56            .and_then(|name| name.to_str())
57            .map(|name| {
58                name == "package-lock.json"
59                    || name == ".package-lock.json"
60                    || name == "npm-shrinkwrap.json"
61                    || name == ".npm-shrinkwrap.json"
62            })
63            .unwrap_or(false)
64    }
65
66    fn extract_packages(path: &Path) -> Vec<PackageData> {
67        let content = match fs::read_to_string(path) {
68            Ok(content) => content,
69            Err(e) => {
70                warn!("Failed to read package-lock.json at {:?}: {}", path, e);
71                return vec![default_package_data()];
72            }
73        };
74
75        let json: Value = match serde_json::from_str(&content) {
76            Ok(json) => json,
77            Err(e) => {
78                warn!("Failed to parse package-lock.json at {:?}: {}", path, e);
79                return vec![default_package_data()];
80            }
81        };
82
83        let lockfile_version = json
84            .get(FIELD_LOCKFILE_VERSION)
85            .and_then(|v| v.as_i64())
86            .unwrap_or(1);
87
88        let root_name = json
89            .get(FIELD_NAME)
90            .and_then(|v| v.as_str())
91            .unwrap_or("")
92            .to_string();
93
94        let root_version = json
95            .get(FIELD_VERSION)
96            .and_then(|v| v.as_str())
97            .unwrap_or("")
98            .to_string();
99
100        vec![if lockfile_version == 1 {
101            parse_lockfile_v1(&json, root_name, root_version, lockfile_version)
102        } else {
103            parse_lockfile_v2_plus(&json, root_name, root_version, lockfile_version)
104        }]
105    }
106}
107
108/// Returns a default empty PackageData for error cases
109fn default_package_data() -> PackageData {
110    PackageData {
111        package_type: Some(NpmLockParser::PACKAGE_TYPE),
112        datasource_id: Some(DatasourceId::NpmPackageLockJson),
113        ..Default::default()
114    }
115}
116
117/// Parse lockfile version 2 or 3 (flat structure with "packages" key)
118fn parse_lockfile_v2_plus(
119    json: &Value,
120    root_name: String,
121    root_version: String,
122    lockfile_version: i64,
123) -> PackageData {
124    let packages = match json.get(FIELD_PACKAGES).and_then(|v| v.as_object()) {
125        Some(packages) => packages,
126        None => {
127            warn!("No 'packages' field found in lockfile v2+");
128            return default_package_data();
129        }
130    };
131
132    let (root_name, root_version) = extract_root_package_identity(json, root_name, root_version);
133    let (namespace, name) = extract_namespace_and_name(&root_name);
134    let purl = create_purl(&namespace, &name, Some(root_version.as_str()));
135
136    // Collect root-level dependencies from top-level sections
137    let mut root_deps = std::collections::HashSet::new();
138
139    // Root dependencies are in top-level "dependencies" and "devDependencies"
140    if let Some(root_deps_obj) = json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
141        for key in root_deps_obj.keys() {
142            root_deps.insert(key.clone());
143        }
144    }
145    if let Some(root_dev_deps_obj) = json.get("devDependencies").and_then(|v| v.as_object()) {
146        for key in root_dev_deps_obj.keys() {
147            root_deps.insert(key.clone());
148        }
149    }
150    if let Some(root_package) = packages.get("").and_then(|value| value.as_object()) {
151        collect_root_dependency_names(root_package.get(FIELD_DEPENDENCIES), &mut root_deps);
152        collect_root_dependency_names(root_package.get("devDependencies"), &mut root_deps);
153        collect_root_dependency_names(root_package.get("optionalDependencies"), &mut root_deps);
154    }
155
156    let mut dependencies = Vec::new();
157
158    for (key, value) in packages {
159        // Skip the root package (empty string key)
160        if key.is_empty() {
161            continue;
162        }
163
164        // Extract package name from path like "node_modules/@types/node" or "node_modules/express"
165        let package_name = extract_package_name_from_path(key);
166        if package_name.is_empty() {
167            continue;
168        }
169
170        let version = value
171            .get(FIELD_VERSION)
172            .and_then(|v| v.as_str())
173            .map(str::to_string);
174
175        let is_dev = value
176            .get(FIELD_DEV)
177            .and_then(|v| v.as_bool())
178            .unwrap_or(false);
179        let is_optional = value
180            .get(FIELD_OPTIONAL)
181            .and_then(|v| v.as_bool())
182            .unwrap_or(false);
183        let is_dev_optional = value
184            .get(FIELD_DEV_OPTIONAL)
185            .and_then(|v| v.as_bool())
186            .unwrap_or(false);
187
188        let resolved = value.get(FIELD_RESOLVED).and_then(|v| v.as_str());
189        let integrity = value.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
190        let from = value.get("from").and_then(|v| v.as_str());
191        let in_bundle = value
192            .get("inBundle")
193            .and_then(|v| v.as_bool())
194            .unwrap_or(false);
195        let is_link = value
196            .get(FIELD_LINK)
197            .and_then(|v| v.as_bool())
198            .unwrap_or(false);
199        let is_direct = root_deps.contains(&package_name) && is_direct_dependency_path(key);
200
201        let dependency = match version {
202            Some(version) => build_npm_dependency(
203                &package_name,
204                version,
205                is_dev,
206                is_dev_optional,
207                is_optional,
208                resolved,
209                integrity,
210                is_direct,
211                from,
212                in_bundle,
213                Vec::new(),
214            ),
215            None if is_link => build_link_dependency(
216                &package_name,
217                is_dev,
218                is_dev_optional,
219                is_optional,
220                resolved,
221                is_direct,
222            ),
223            None => continue,
224        };
225
226        dependencies.push(dependency);
227    }
228
229    let extra_data = Some(HashMap::from([(
230        "lockfileVersion".to_string(),
231        Value::from(lockfile_version),
232    )]));
233
234    PackageData {
235        package_type: Some(NpmLockParser::PACKAGE_TYPE),
236        namespace: Some(namespace),
237        name: Some(name),
238        version: Some(root_version),
239        qualifiers: None,
240        subpath: None,
241        primary_language: None,
242        description: None,
243        release_date: None,
244        parties: Vec::new(),
245        keywords: Vec::new(),
246        homepage_url: None,
247        download_url: None,
248        size: None,
249        sha1: None,
250        md5: None,
251        sha256: None,
252        sha512: None,
253        bug_tracking_url: None,
254        code_view_url: None,
255        vcs_url: None,
256        copyright: None,
257        holder: None,
258        declared_license_expression: None,
259        declared_license_expression_spdx: None,
260        license_detections: Vec::new(),
261        other_license_expression: None,
262        other_license_expression_spdx: None,
263        other_license_detections: Vec::new(),
264        extracted_license_statement: None,
265        notice_text: None,
266        source_packages: Vec::new(),
267        file_references: Vec::new(),
268        is_private: false,
269        is_virtual: false,
270        extra_data,
271        dependencies,
272        repository_homepage_url: None,
273        repository_download_url: None,
274        api_data_url: None,
275        datasource_id: Some(DatasourceId::NpmPackageLockJson),
276        purl,
277    }
278}
279
280/// Parse lockfile version 1 (nested structure with "dependencies" key)
281fn parse_lockfile_v1(
282    json: &Value,
283    root_name: String,
284    root_version: String,
285    _lockfile_version: i64,
286) -> PackageData {
287    let dependencies_obj = match json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
288        Some(deps) => deps,
289        None => {
290            warn!("No 'dependencies' field found in lockfile v1");
291            return default_package_data();
292        }
293    };
294
295    let (namespace, name) = extract_namespace_and_name(&root_name);
296    let purl = create_purl(&namespace, &name, Some(root_version.as_str()));
297
298    let dependencies = parse_dependencies_v1(dependencies_obj);
299
300    PackageData {
301        package_type: Some(NpmLockParser::PACKAGE_TYPE),
302        namespace: Some(namespace),
303        name: Some(name),
304        version: Some(root_version),
305        qualifiers: None,
306        subpath: None,
307        primary_language: None,
308        description: None,
309        release_date: None,
310        parties: Vec::new(),
311        keywords: Vec::new(),
312        homepage_url: None,
313        download_url: None,
314        size: None,
315        sha1: None,
316        md5: None,
317        sha256: None,
318        sha512: None,
319        bug_tracking_url: None,
320        code_view_url: None,
321        vcs_url: None,
322        copyright: None,
323        holder: None,
324        declared_license_expression: None,
325        declared_license_expression_spdx: None,
326        license_detections: Vec::new(),
327        other_license_expression: None,
328        other_license_expression_spdx: None,
329        other_license_detections: Vec::new(),
330        extracted_license_statement: None,
331        notice_text: None,
332        source_packages: Vec::new(),
333        file_references: Vec::new(),
334        is_private: false,
335        is_virtual: false,
336        extra_data: None,
337        dependencies,
338        repository_homepage_url: None,
339        repository_download_url: None,
340        api_data_url: None,
341        datasource_id: Some(DatasourceId::NpmPackageLockJson),
342        purl,
343    }
344}
345
346/// Recursively parse v1 dependencies object
347///
348/// For v1 lockfiles, root dependencies are at nesting level 0 (direct children of the root
349/// "dependencies" object). Transitive dependencies are nested within parent dependencies.
350fn parse_dependencies_v1(dependencies_obj: &serde_json::Map<String, Value>) -> Vec<Dependency> {
351    parse_dependencies_v1_with_depth(dependencies_obj, 0)
352}
353
354/// Recursively parse v1 dependencies with depth tracking
355fn parse_dependencies_v1_with_depth(
356    dependencies_obj: &serde_json::Map<String, Value>,
357    depth: usize,
358) -> Vec<Dependency> {
359    let mut dependencies = Vec::new();
360
361    for (package_name, dep_data) in dependencies_obj {
362        let version = match dep_data.get(FIELD_VERSION).and_then(|v| v.as_str()) {
363            Some(v) => v.to_string(),
364            None => continue,
365        };
366
367        let is_dev = dep_data
368            .get(FIELD_DEV)
369            .and_then(|v| v.as_bool())
370            .unwrap_or(false);
371        let is_optional = dep_data
372            .get(FIELD_OPTIONAL)
373            .and_then(|v| v.as_bool())
374            .unwrap_or(false);
375
376        let resolved = dep_data.get(FIELD_RESOLVED).and_then(|v| v.as_str());
377        let integrity = dep_data.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
378        let from = dep_data.get("from").and_then(|v| v.as_str());
379        let in_bundle = dep_data
380            .get("inBundle")
381            .and_then(|v| v.as_bool())
382            .unwrap_or(false);
383
384        let nested_deps = dep_data
385            .get(FIELD_DEPENDENCIES)
386            .and_then(|v| v.as_object())
387            .map(|nested| parse_dependencies_v1_with_depth(nested, depth + 1))
388            .unwrap_or_default();
389
390        let is_direct = depth == 0;
391
392        let dependency = build_npm_dependency(
393            package_name,
394            version,
395            is_dev,
396            false, // v1 lockfiles don't have devOptional flag
397            is_optional,
398            resolved,
399            integrity,
400            is_direct,
401            from,
402            in_bundle,
403            nested_deps,
404        );
405
406        dependencies.push(dependency);
407    }
408
409    dependencies
410}
411
412/// Extract namespace and name from a package name like "@types/node" or "express"
413/// Returns: (namespace, name) where namespace is empty string "" for non-scoped packages
414fn extract_namespace_and_name(package_name: &str) -> (String, String) {
415    if package_name.starts_with('@') {
416        // Scoped package like "@types/node"
417        let parts: Vec<&str> = package_name.splitn(2, '/').collect();
418        if parts.len() == 2 {
419            (parts[0].to_string(), parts[1].to_string())
420        } else {
421            // Invalid format, treat as non-scoped
422            (String::new(), package_name.to_string())
423        }
424    } else {
425        // Regular package like "express"
426        (String::new(), package_name.to_string())
427    }
428}
429
430/// Extract package name from path like "node_modules/@types/node" or "node_modules/express"
431fn extract_package_name_from_path(path: &str) -> String {
432    // Find the last occurrence of "node_modules/"
433    if let Some(pos) = path.rfind("node_modules/") {
434        let after_node_modules = &path[pos + "node_modules/".len()..];
435
436        // Handle scoped packages: "@scope/package"
437        if after_node_modules.starts_with('@') {
438            // Find the second slash (after @scope/)
439            if let Some(slash_pos) = after_node_modules.find('/') {
440                let scope_and_package = &after_node_modules[..=slash_pos];
441                // Find if there's another segment after the package name
442                let remaining = &after_node_modules[slash_pos + 1..];
443                if let Some(next_slash) = remaining.find('/') {
444                    // Return just @scope/package
445                    return format!("{}{}", scope_and_package, &remaining[..next_slash]);
446                } else {
447                    // Return the full scoped package name
448                    return after_node_modules.to_string();
449                }
450            }
451        } else {
452            // Regular package: take everything until first slash (or end of string)
453            if let Some(slash_pos) = after_node_modules.find('/') {
454                return after_node_modules[..slash_pos].to_string();
455            } else {
456                return after_node_modules.to_string();
457            }
458        }
459    }
460
461    path.to_string()
462}
463
464fn create_purl(namespace: &str, name: &str, version: Option<&str>) -> Option<String> {
465    let full_name = if namespace.is_empty() {
466        name.to_string()
467    } else {
468        format!("{}/{}", namespace, name)
469    };
470    npm_purl(&full_name, version.filter(|value| !value.is_empty()))
471}
472
473fn extract_root_package_identity(
474    json: &Value,
475    root_name: String,
476    root_version: String,
477) -> (String, String) {
478    let root_package = json
479        .get(FIELD_PACKAGES)
480        .and_then(|value| value.as_object())
481        .and_then(|packages| packages.get(""))
482        .and_then(|value| value.as_object());
483
484    let name = non_empty_string(&root_name).or_else(|| {
485        root_package
486            .and_then(|package| package.get(FIELD_NAME))
487            .and_then(|value| value.as_str())
488            .map(str::to_string)
489            .filter(|value| !value.trim().is_empty())
490    });
491    let version = non_empty_string(&root_version).or_else(|| {
492        root_package
493            .and_then(|package| package.get(FIELD_VERSION))
494            .and_then(|value| value.as_str())
495            .map(str::to_string)
496            .filter(|value| !value.trim().is_empty())
497    });
498
499    (name.unwrap_or_default(), version.unwrap_or_default())
500}
501
502fn non_empty_string(value: &str) -> Option<String> {
503    let trimmed = value.trim();
504    if trimmed.is_empty() {
505        None
506    } else {
507        Some(trimmed.to_string())
508    }
509}
510
511fn collect_root_dependency_names(
512    value: Option<&Value>,
513    root_deps: &mut std::collections::HashSet<String>,
514) {
515    if let Some(entries) = value.and_then(|value| value.as_object()) {
516        for key in entries.keys() {
517            root_deps.insert(key.clone());
518        }
519    }
520}
521
522fn is_direct_dependency_path(package_path: &str) -> bool {
523    let node_modules_count = package_path.matches("node_modules/").count();
524
525    match node_modules_count {
526        0 => true,
527        1 => package_path.starts_with("node_modules/") || package_path.starts_with(".pnpm/"),
528        _ => false,
529    }
530}
531
532/// Parse integrity field like "sha512-base64string==" or "sha1-base64string="
533/// Returns: (sha1, sha512) as hex strings
534fn parse_integrity_field(integrity: Option<&str>) -> (Option<String>, Option<String>) {
535    let integrity = match integrity {
536        Some(i) => i,
537        None => return (None, None),
538    };
539
540    match parse_sri(integrity) {
541        Some((algo, hex_digest)) => match algo.as_str() {
542            "sha1" => (Some(hex_digest), None),
543            "sha512" => (None, Some(hex_digest)),
544            _ => (None, None),
545        },
546        None => (None, None),
547    }
548}
549
550/// Parse resolved URL to extract sha1 checksum if present
551/// Example: "https://registry.npmjs.org/package/-/package-1.0.0.tgz#abc123def"
552fn parse_resolved_url(url: &str) -> Option<String> {
553    // Look for # followed by hex characters
554    if let Some(hash_pos) = url.rfind('#') {
555        let hash = &url[hash_pos + 1..];
556        // Verify it's a hex string (sha1 is 40 characters)
557        if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
558            return Some(hash.to_string());
559        }
560    }
561    None
562}
563
564/// Determine scope, is_runtime, and is_optional based on dev/optional flags
565/// Returns: (scope, is_runtime, is_optional)
566fn determine_scope(
567    is_dev: bool,
568    is_dev_optional: bool,
569    is_optional: bool,
570) -> (&'static str, bool, bool) {
571    if is_dev || is_dev_optional {
572        ("devDependencies", false, true)
573    } else if is_optional {
574        ("dependencies", true, true)
575    } else {
576        ("dependencies", true, false)
577    }
578}
579
580fn parse_npm_alias_spec(version_spec: &str) -> Option<(String, String, String)> {
581    let aliased_spec = version_spec.strip_prefix("npm:")?;
582    let (aliased_name, constraint) = aliased_spec.rsplit_once('@')?;
583    let (namespace, name) = extract_namespace_and_name(aliased_name);
584
585    if name.is_empty() || constraint.trim().is_empty() {
586        None
587    } else {
588        Some((namespace, name, constraint.to_string()))
589    }
590}
591
592fn is_exact_version(version: &str) -> bool {
593    let version = version.trim();
594
595    if version.is_empty() {
596        return false;
597    }
598
599    if version.starts_with('~')
600        || version.starts_with('^')
601        || version.starts_with('>')
602        || version.starts_with('<')
603        || version.starts_with('=')
604        || version.starts_with('*')
605        || version.contains("||")
606        || version.contains(" - ")
607    {
608        return false;
609    }
610
611    !is_non_version_dependency(version)
612}
613
614fn is_non_version_dependency(version: &str) -> bool {
615    let version = version.trim();
616
617    version.starts_with("http://")
618        || version.starts_with("https://")
619        || version.starts_with("git://")
620        || version.starts_with("git+ssh://")
621        || version.starts_with("git+http://")
622        || version.starts_with("git+https://")
623        || version.starts_with("git+file://")
624        || version.starts_with("git@")
625        || version.starts_with("file:")
626        || version.starts_with("link:")
627        || version.starts_with("github:")
628        || version.starts_with("gitlab:")
629        || version.starts_with("bitbucket:")
630        || version.starts_with("gist:")
631}
632
633fn non_version_download_url(version: &str, resolved: Option<&str>) -> Option<String> {
634    resolved
635        .map(str::to_string)
636        .or_else(|| match version.trim() {
637            version if version.starts_with("http://") || version.starts_with("https://") => {
638                Some(version.to_string())
639            }
640            _ => None,
641        })
642}
643
644#[allow(clippy::too_many_arguments)]
645fn build_npm_dependency(
646    package_name: &str,
647    version: String,
648    is_dev: bool,
649    is_dev_optional: bool,
650    is_optional: bool,
651    resolved: Option<&str>,
652    integrity: Option<&str>,
653    is_direct: bool,
654    from: Option<&str>,
655    in_bundle: bool,
656    nested_deps: Vec<Dependency>,
657) -> Dependency {
658    let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
659    let (scope, is_runtime, is_optional_flag) =
660        determine_scope(is_dev, is_dev_optional, is_optional);
661
662    let alias_spec = parse_npm_alias_spec(&version);
663    let (purl_namespace, purl_name, resolved_version, is_pinned, dep_purl, download_url) =
664        if let Some((alias_namespace, alias_name, alias_constraint)) = alias_spec.clone() {
665            let is_pinned = is_exact_version(&alias_constraint);
666            let dep_purl = create_purl(
667                &alias_namespace,
668                &alias_name,
669                is_pinned.then_some(alias_constraint.as_str()),
670            );
671            let download_url = non_version_download_url(&alias_constraint, resolved);
672
673            (
674                alias_namespace,
675                alias_name,
676                alias_constraint,
677                is_pinned,
678                dep_purl,
679                download_url,
680            )
681        } else {
682            let is_pinned = is_exact_version(&version);
683            let dep_purl = create_purl(
684                &dep_namespace,
685                &dep_name,
686                is_pinned.then_some(version.as_str()),
687            );
688            let download_url = non_version_download_url(&version, resolved);
689
690            (
691                dep_namespace.clone(),
692                dep_name.clone(),
693                version.clone(),
694                is_pinned,
695                dep_purl,
696                download_url,
697            )
698        };
699
700    let (sha1_from_integrity, sha512_from_integrity) = parse_integrity_field(integrity);
701    let sha1_from_url = resolved.and_then(parse_resolved_url);
702    let sha1 = sha1_from_integrity.or(sha1_from_url);
703
704    let mut dep_extra_data = HashMap::new();
705    if let Some(from) = from {
706        dep_extra_data.insert("from".to_string(), Value::String(from.to_string()));
707    }
708    if in_bundle {
709        dep_extra_data.insert("inBundle".to_string(), Value::Bool(true));
710    }
711
712    let resolved_package = ResolvedPackage {
713        package_type: NpmLockParser::PACKAGE_TYPE,
714        namespace: purl_namespace,
715        name: purl_name,
716        version: resolved_version,
717        primary_language: Some("JavaScript".to_string()),
718        download_url,
719        sha1,
720        sha256: None,
721        sha512: sha512_from_integrity,
722        md5: None,
723        is_virtual: true,
724        extra_data: None,
725        dependencies: nested_deps,
726        repository_homepage_url: None,
727        repository_download_url: None,
728        api_data_url: None,
729        datasource_id: Some(DatasourceId::NpmPackageLockJson),
730        purl: None,
731    };
732
733    Dependency {
734        purl: dep_purl,
735        extracted_requirement: Some(version),
736        scope: Some(scope.to_string()),
737        is_runtime: Some(is_runtime),
738        is_optional: Some(is_optional_flag),
739        is_pinned: Some(is_pinned),
740        is_direct: Some(is_direct),
741        resolved_package: Some(Box::new(resolved_package)),
742        extra_data: (!dep_extra_data.is_empty()).then_some(dep_extra_data),
743    }
744}
745
746fn build_link_dependency(
747    package_name: &str,
748    is_dev: bool,
749    is_dev_optional: bool,
750    is_optional: bool,
751    resolved: Option<&str>,
752    is_direct: bool,
753) -> Dependency {
754    let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
755    let (scope, is_runtime, is_optional_flag) =
756        determine_scope(is_dev, is_dev_optional, is_optional);
757    let mut extra_data = HashMap::from([("link".to_string(), Value::Bool(true))]);
758
759    if let Some(resolved) = resolved {
760        extra_data.insert("resolved".to_string(), Value::String(resolved.to_string()));
761    }
762
763    Dependency {
764        purl: create_purl(&dep_namespace, &dep_name, None),
765        extracted_requirement: resolved.map(str::to_string),
766        scope: Some(scope.to_string()),
767        is_runtime: Some(is_runtime),
768        is_optional: Some(is_optional_flag),
769        is_pinned: Some(false),
770        is_direct: Some(is_direct),
771        resolved_package: None,
772        extra_data: Some(extra_data),
773    }
774}
775
776crate::register_parser!(
777    "npm package-lock.json lockfile",
778    &[
779        "**/package-lock.json",
780        "**/.package-lock.json",
781        "**/npm-shrinkwrap.json"
782    ],
783    "npm",
784    "JavaScript",
785    Some("https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json"),
786);