From 109b2f724fcdbf0797469ae9d6b801976e9f5a0e Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:48 +0200 Subject: [PATCH 01/70] 01/70 npm-health: add helper module foundation Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 scanpipe/pipes/npm_health.py diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py new file mode 100644 index 0000000000..3207290abd --- /dev/null +++ b/scanpipe/pipes/npm_health.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# http://nexb.com and https://github.com/aboutcode-org/scancode.io +# The ScanCode.io software is licensed under the Apache License version 2.0. +# Data generated with ScanCode.io is provided as-is without warranties. +# ScanCode is a trademark of nexB Inc. +# +# You may not use this software except in compliance with the License. +# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Utilities for the npm-health ScanCode.io pipeline.""" + +import json +import shlex +import subprocess +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from urllib.parse import quote +from urllib.parse import urlparse +from urllib.parse import urlunparse + +import requests +from packageurl import PackageURL + + +NPM_HEALTH_EXTRA_DATA_KEY = "npm_health" +DEFAULT_CACHE_MAX_AGE_DAYS = 90 +DEFAULT_REQUEST_TIMEOUT = 30 +DEFAULT_COMMAND_TIMEOUT = 900 + +DEFAULT_METRIC_WEIGHTS = { + "activity": 1.0, + "community": 1.0, + "maintenance": 1.0, + "documentation": 0.75, + "security": 1.0, + "metadata_completeness": 0.5, + "maintainer_presence": 0.5, + "dependency_simplicity": 0.25, +} From e4daf6abf46c18470d114408bbe9409a418b4518 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:49 +0200 Subject: [PATCH 02/70] 02/70 npm-health: define pipeline errors Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 3207290abd..0fb66257ea 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -44,3 +44,15 @@ "maintainer_presence": 0.5, "dependency_simplicity": 0.25, } + + +class NpmHealthError(Exception): + """Base npm-health error.""" + + +class NpmHealthPayloadError(NpmHealthError): + """Invalid PURL, registry data, or metrics payload.""" + + +class NpmHealthCommandError(NpmHealthError): + """External metrics collector failure.""" From d676633c6a2266d64d21b3cf98c8c5ee9647142f Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:49 +0200 Subject: [PATCH 03/70] 03/70 npm-health: parse package URLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 0fb66257ea..147c3d399e 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -56,3 +56,14 @@ class NpmHealthPayloadError(NpmHealthError): class NpmHealthCommandError(NpmHealthError): """External metrics collector failure.""" + + + +def parse_package_url(value): + """Return a PackageURL parsed from ``value``.""" + if not value or not isinstance(value, str): + raise NpmHealthPayloadError("A project PURL is required.") + try: + return PackageURL.from_string(value) + except ValueError as error: + raise NpmHealthPayloadError(f"Invalid package URL: {value}") from error From f0c395ae6341319065b15ce753e49495636f3f79 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:49 +0200 Subject: [PATCH 04/70] 04/70 npm-health: validate versioned npm PURLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 147c3d399e..598ae8743b 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -67,3 +67,18 @@ def parse_package_url(value): return PackageURL.from_string(value) except ValueError as error: raise NpmHealthPayloadError(f"Invalid package URL: {value}") from error + + + +def validate_npm_package_url(value): + """Return a versioned npm PackageURL or raise a descriptive error.""" + package = parse_package_url(value) + if package.type != "npm": + raise NpmHealthPayloadError( + f"npm-health requires an npm PURL, not {package.type!r}." + ) + if not package.name: + raise NpmHealthPayloadError("npm-health requires a package name.") + if not package.version: + raise NpmHealthPayloadError("npm-health requires a package version.") + return package From c77dfe33ca975ddacb25e94fde2112bb234bd0a8 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:49 +0200 Subject: [PATCH 05/70] 05/70 npm-health: support scoped npm package names Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 598ae8743b..3ae3ea6fb4 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -82,3 +82,14 @@ def validate_npm_package_url(value): if not package.version: raise NpmHealthPayloadError("npm-health requires a package version.") return package + + + +def get_package_name(package): + """Return the npm registry name for a PackageURL.""" + if not package.namespace: + return package.name + namespace = package.namespace + if not namespace.startswith("@"): + namespace = f"@{namespace}" + return f"{namespace}/{package.name}" From 32da568c82c48f3f2274a100b14dc76ba0d91cd5 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:50 +0200 Subject: [PATCH 06/70] 06/70 npm-health: build registry metadata URLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 3ae3ea6fb4..5dce0166a9 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -93,3 +93,11 @@ def get_package_name(package): if not namespace.startswith("@"): namespace = f"@{namespace}" return f"{namespace}/{package.name}" + + + +def get_registry_metadata_url(package): + """Return the npm registry URL for one exact package version.""" + name = quote(get_package_name(package), safe="@") + version = quote(package.version, safe="") + return f"https://registry.npmjs.org/{name}/{version}" From a65c89ffd811f994ac7953c06f0f7e9782853f2e Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:50 +0200 Subject: [PATCH 07/70] 07/70 npm-health: normalize repository URLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 5dce0166a9..7ca526d472 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -101,3 +101,26 @@ def get_registry_metadata_url(package): name = quote(get_package_name(package), safe="@") version = quote(package.version, safe="") return f"https://registry.npmjs.org/{name}/{version}" + + + +def normalize_repository_url(repository): + """Return a normalized HTTP(S) repository URL.""" + if isinstance(repository, dict): + repository = repository.get("url") + if not repository or not isinstance(repository, str): + return "" + + value = repository.strip() + if value.startswith("git+"): + value = value[4:] + if value.startswith("git@github.com:"): + value = "https://github.com/" + value.removeprefix("git@github.com:") + if value.startswith("git://github.com/"): + value = "https://github.com/" + value.removeprefix("git://github.com/") + + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return "" + path = parsed.path.removesuffix(".git").rstrip("/") + return urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")) From 78ad4ca16a745419b10a343288b4d74bfd57a187 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:50 +0200 Subject: [PATCH 08/70] 08/70 npm-health: extract repository metadata Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 7ca526d472..15d8b37e5b 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -124,3 +124,9 @@ def normalize_repository_url(repository): return "" path = parsed.path.removesuffix(".git").rstrip("/") return urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")) + + + +def get_repository_url(metadata): + """Return a normalized repository URL from npm metadata.""" + return normalize_repository_url(metadata.get("repository")) From 5bce0c2f67b9f20515a6550fc718e9a6f4f227ba Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:51 +0200 Subject: [PATCH 09/70] 09/70 npm-health: extract package tarball URLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 15d8b37e5b..ab37daf266 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -130,3 +130,11 @@ def normalize_repository_url(repository): def get_repository_url(metadata): """Return a normalized repository URL from npm metadata.""" return normalize_repository_url(metadata.get("repository")) + + + +def get_tarball_url(metadata): + """Return the npm distribution tarball URL.""" + dist = metadata.get("dist") or {} + value = dist.get("tarball") + return value if isinstance(value, str) else "" From a4817e7366bcad7c4d4549ce7a791352f56b2790 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:51 +0200 Subject: [PATCH 10/70] 10/70 npm-health: extract package homepage URLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index ab37daf266..ce65bacb2e 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -138,3 +138,10 @@ def get_tarball_url(metadata): dist = metadata.get("dist") or {} value = dist.get("tarball") return value if isinstance(value, str) else "" + + + +def get_homepage_url(metadata): + """Return the package homepage URL when available.""" + value = metadata.get("homepage") + return value if isinstance(value, str) else "" From 8f91b47f47ace149db02e1740f14df4e69b02b2e Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:51 +0200 Subject: [PATCH 11/70] 11/70 npm-health: extract package license metadata Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index ce65bacb2e..63e3f1cfe4 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -145,3 +145,14 @@ def get_homepage_url(metadata): """Return the package homepage URL when available.""" value = metadata.get("homepage") return value if isinstance(value, str) else "" + + + +def get_license(metadata): + """Return a compact license value from npm metadata.""" + value = metadata.get("license") + if isinstance(value, str): + return value + if isinstance(value, dict) and isinstance(value.get("type"), str): + return value["type"] + return "" From 4ed30604996fa8d01c9b67c3c46dc76ef31c3885 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:51 +0200 Subject: [PATCH 12/70] 12/70 npm-health: count npm maintainers Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 63e3f1cfe4..136770963c 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -156,3 +156,10 @@ def get_license(metadata): if isinstance(value, dict) and isinstance(value.get("type"), str): return value["type"] return "" + + + +def get_maintainer_count(metadata): + """Return the number of maintainers declared by npm.""" + maintainers = metadata.get("maintainers") or [] + return len(maintainers) if isinstance(maintainers, list) else 0 From a64fb1a3e59fb690f187b394f5cf3de3ed947e28 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:52 +0200 Subject: [PATCH 13/70] 13/70 npm-health: count runtime dependencies Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 136770963c..5a617efe85 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -163,3 +163,10 @@ def get_maintainer_count(metadata): """Return the number of maintainers declared by npm.""" maintainers = metadata.get("maintainers") or [] return len(maintainers) if isinstance(maintainers, list) else 0 + + + +def get_dependency_count(metadata): + """Return the number of runtime dependencies in npm metadata.""" + dependencies = metadata.get("dependencies") or {} + return len(dependencies) if isinstance(dependencies, dict) else 0 From a8f95d950c8190c47fbef8cc9b633f5b86be3a40 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:52 +0200 Subject: [PATCH 14/70] 14/70 npm-health: fetch exact npm registry metadata Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 5a617efe85..33248e3194 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -170,3 +170,18 @@ def get_dependency_count(metadata): """Return the number of runtime dependencies in npm metadata.""" dependencies = metadata.get("dependencies") or {} return len(dependencies) if isinstance(dependencies, dict) else 0 + + + +def fetch_registry_metadata( + package, + session=requests, + timeout=DEFAULT_REQUEST_TIMEOUT, +): + """Fetch and return npm registry metadata for ``package``.""" + response = session.get(get_registry_metadata_url(package), timeout=timeout) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise NpmHealthPayloadError("The npm registry returned a non-object payload.") + return data From 4262ae1842649e1cceb37b40fb98a9c7aca52491 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:52 +0200 Subject: [PATCH 15/70] 15/70 npm-health: clamp normalized values Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 33248e3194..8879562934 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -185,3 +185,9 @@ def fetch_registry_metadata( if not isinstance(data, dict): raise NpmHealthPayloadError("The npm registry returned a non-object payload.") return data + + + +def clamp(value, minimum=0.0, maximum=1.0): + """Clamp a numeric value to an inclusive range.""" + return max(minimum, min(maximum, value)) From 8b437788248bd1af6323429def5a8ba5ecb53a70 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:53 +0200 Subject: [PATCH 16/70] 16/70 npm-health: normalize metric values Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 8879562934..22927eec48 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -191,3 +191,16 @@ def fetch_registry_metadata( def clamp(value, minimum=0.0, maximum=1.0): """Clamp a numeric value to an inclusive range.""" return max(minimum, min(maximum, value)) + + + +def normalize_metric_value(value): + """Normalize bool, 0..1, or percentage metric values to 0..1.""" + if isinstance(value, bool): + return float(value) + if not isinstance(value, int | float): + raise NpmHealthPayloadError(f"Unsupported metric value: {value!r}") + normalized = float(value) + if normalized > 1.0: + normalized /= 100.0 + return clamp(normalized) From ecf9ca543c568463d4ef367cf9f9f722e19fa67a Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:53 +0200 Subject: [PATCH 17/70] 17/70 npm-health: normalize collector payloads Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 22927eec48..40f9791ee5 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -204,3 +204,18 @@ def normalize_metric_value(value): if normalized > 1.0: normalized /= 100.0 return clamp(normalized) + + + +def normalize_metrics(payload): + """Return normalized metrics from a direct or nested collector payload.""" + if not isinstance(payload, dict): + raise NpmHealthPayloadError("Metrics payload must be a JSON object.") + values = payload.get("metrics", payload) + if not isinstance(values, dict): + raise NpmHealthPayloadError("Metrics must be a JSON object.") + return { + name: normalize_metric_value(value) + for name, value in values.items() + if isinstance(name, str) + } From 32a9a8003c9f5ae14fd0a777dfd35b998e9c411c Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:53 +0200 Subject: [PATCH 18/70] 18/70 npm-health: derive baseline registry metrics Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 40f9791ee5..ed0ac6536f 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -219,3 +219,20 @@ def normalize_metrics(payload): for name, value in values.items() if isinstance(name, str) } + + + +def collect_registry_metrics(metadata): + """Return baseline health signals available from npm registry metadata.""" + completeness = sum( + ( + bool(get_repository_url(metadata)), + bool(get_homepage_url(metadata)), + bool(get_license(metadata)), + ) + ) / 3 + return { + "metadata_completeness": completeness, + "maintainer_presence": clamp(get_maintainer_count(metadata) / 3), + "dependency_simplicity": 1.0 - clamp(get_dependency_count(metadata) / 50), + } From c959a20023b6e14a22c3bf22a682b37d32b5ce24 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:53 +0200 Subject: [PATCH 19/70] 19/70 npm-health: merge external collector metrics Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index ed0ac6536f..ad585cc3bb 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -236,3 +236,13 @@ def collect_registry_metrics(metadata): "maintainer_presence": clamp(get_maintainer_count(metadata) / 3), "dependency_simplicity": 1.0 - clamp(get_dependency_count(metadata) / 50), } + + + +def merge_metrics(*metric_sets): + """Merge normalized metric mappings from left to right.""" + merged = {} + for metrics in metric_sets: + if metrics: + merged.update(normalize_metrics(metrics)) + return merged From 0d507f93d2a59188a834a09f06cc0445fe89e30a Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:54 +0200 Subject: [PATCH 20/70] 20/70 npm-health: normalize scoring weights Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index ad585cc3bb..ae8c03fe57 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -246,3 +246,14 @@ def merge_metrics(*metric_sets): if metrics: merged.update(normalize_metrics(metrics)) return merged + + + +def normalize_weights(weights=None): + """Return positive numeric scoring weights.""" + weights = weights or DEFAULT_METRIC_WEIGHTS + return { + name: float(value) + for name, value in weights.items() + if isinstance(name, str) and isinstance(value, int | float) and value > 0 + } From a03c5e4736784761112e8b4bddd07b596b7869f0 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:54 +0200 Subject: [PATCH 21/70] 21/70 npm-health: compute weighted health scores Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index ae8c03fe57..327f2e08d9 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -257,3 +257,20 @@ def normalize_weights(weights=None): for name, value in weights.items() if isinstance(name, str) and isinstance(value, int | float) and value > 0 } + + + +def compute_health_score(metrics, weights=None): + """Return a weighted package health score from 0 to 100.""" + metrics = normalize_metrics(metrics) + weights = normalize_weights(weights) + weighted = [ + (value, weights[name]) + for name, value in metrics.items() + if name in weights + ] + denominator = sum(weight for _, weight in weighted) + if not denominator: + return 0.0 + numerator = sum(value * weight for value, weight in weighted) + return round((numerator / denominator) * 100, 2) From 7ceab4f95aaef87c849ffbb6f09a59e13f64f5fb Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:54 +0200 Subject: [PATCH 22/70] 22/70 npm-health: classify package health scores Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 327f2e08d9..07fda5ea81 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -274,3 +274,15 @@ def compute_health_score(metrics, weights=None): return 0.0 numerator = sum(value * weight for value, weight in weighted) return round((numerator / denominator) * 100, 2) + + + +def classify_health_score(score): + """Return a qualitative classification for a numeric health score.""" + if score >= 80: + return "excellent" + if score >= 60: + return "good" + if score >= 40: + return "needs-attention" + return "high-risk" From d12c0b18f6acf0f67939633da94d3aa617257123 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:54 +0200 Subject: [PATCH 23/70] 23/70 npm-health: parse cached collection timestamps Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 07fda5ea81..aa54d58caa 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -286,3 +286,17 @@ def classify_health_score(score): if score >= 40: return "needs-attention" return "high-risk" + + + +def parse_timestamp(value): + """Return an aware UTC datetime parsed from an ISO timestamp.""" + if not value or not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) From acb1669fc00834c6877a0095ddf13895c5630c42 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:55 +0200 Subject: [PATCH 24/70] 24/70 npm-health: detect stale cached analyses Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index aa54d58caa..4a1dddaf60 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -300,3 +300,15 @@ def parse_timestamp(value): if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=UTC) return parsed.astimezone(UTC) + + + +def is_stale(snapshot, max_age_days=DEFAULT_CACHE_MAX_AGE_DAYS, now=None): + """Return True when a cached npm-health snapshot is missing or too old.""" + if not isinstance(snapshot, dict): + return True + collected_at = parse_timestamp(snapshot.get("collected_at")) + if not collected_at: + return True + now = now or datetime.now(UTC) + return now - collected_at > timedelta(days=max_age_days) From 0df7941e91f5eb65dd1cef0390af31f98ff76619 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:55 +0200 Subject: [PATCH 25/70] 25/70 npm-health: expose collection targets Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 4a1dddaf60..1b25ca4ee0 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -312,3 +312,13 @@ def is_stale(snapshot, max_age_days=DEFAULT_CACHE_MAX_AGE_DAYS, now=None): return True now = now or datetime.now(UTC) return now - collected_at > timedelta(days=max_age_days) + + + +def build_collection_targets(metadata): + """Return source locations useful to external health collectors.""" + return { + "repository_url": get_repository_url(metadata), + "tarball_url": get_tarball_url(metadata), + "homepage_url": get_homepage_url(metadata), + } From 140d9fe719f9f4ec7ef53c8938aca1e7acb60fac Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:55 +0200 Subject: [PATCH 26/70] 26/70 npm-health: build external collector context Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 1b25ca4ee0..af540d8bcd 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -322,3 +322,15 @@ def build_collection_targets(metadata): "tarball_url": get_tarball_url(metadata), "homepage_url": get_homepage_url(metadata), } + + + +def build_command_context(purl, metadata, output): + """Return safe substitutions for an external collector command.""" + targets = build_collection_targets(metadata) + return { + "purl": purl, + "repository_url": targets["repository_url"], + "tarball_url": targets["tarball_url"], + "output": str(output), + } From bb82957a3ad52fb84fbe7366796abc23191743cd Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:56 +0200 Subject: [PATCH 27/70] 27/70 npm-health: render shell-free collector commands Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index af540d8bcd..32301714c1 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -334,3 +334,20 @@ def build_command_context(purl, metadata, output): "tarball_url": targets["tarball_url"], "output": str(output), } + + + +def render_metrics_command(command_template, context): + """Render a command template to an argument list without a shell.""" + if not command_template or not isinstance(command_template, str): + raise NpmHealthCommandError("npm_health_metrics_command is not configured.") + try: + rendered = command_template.format_map(context) + except KeyError as error: + raise NpmHealthCommandError( + f"Unknown npm-health command placeholder: {error.args[0]}" + ) from error + args = shlex.split(rendered) + if not args: + raise NpmHealthCommandError("The rendered metrics command is empty.") + return args From 79877972a598dd7f2f0ae952bca5742082b7137b Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:56 +0200 Subject: [PATCH 28/70] 28/70 npm-health: execute external metrics collectors Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 32301714c1..00ba742ce0 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -351,3 +351,22 @@ def render_metrics_command(command_template, context): if not args: raise NpmHealthCommandError("The rendered metrics command is empty.") return args + + + +def run_metrics_command(args, cwd=None, timeout=DEFAULT_COMMAND_TIMEOUT): + """Run an external metrics collector and return its completed process.""" + completed = subprocess.run( # noqa: S603 + args, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + if completed.returncode: + details = (completed.stderr or completed.stdout or "").strip() + raise NpmHealthCommandError( + f"External metrics collector failed ({completed.returncode}): {details}" + ) + return completed From 32cf473db93f05c78cf53b02d6a20cfb250025e9 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:56 +0200 Subject: [PATCH 29/70] 29/70 npm-health: load external metrics JSON Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 00ba742ce0..8992b40fab 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -370,3 +370,19 @@ def run_metrics_command(args, cwd=None, timeout=DEFAULT_COMMAND_TIMEOUT): f"External metrics collector failed ({completed.returncode}): {details}" ) return completed + + + +def load_metrics_json(location): + """Load and normalize metrics from a JSON output file.""" + location = Path(location) + if not location.is_file(): + raise NpmHealthCommandError( + f"External metrics output was not created: {location}" + ) + try: + payload = json.loads(location.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + message = "External metrics output is invalid JSON." + raise NpmHealthPayloadError(message) from error + return normalize_metrics(payload) From c0e0749a3385dd2c1c557aac84f62fb35bcd97d2 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:56 +0200 Subject: [PATCH 30/70] 30/70 npm-health: integrate external metrics adapter Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 8992b40fab..06055db763 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -386,3 +386,18 @@ def load_metrics_json(location): message = "External metrics output is invalid JSON." raise NpmHealthPayloadError(message) from error return normalize_metrics(payload) + + + +def collect_external_metrics( + command_template, + purl, + metadata, + output, + cwd=None, +): + """Run a configured external collector and return normalized metrics.""" + context = build_command_context(purl, metadata, output) + args = render_metrics_command(command_template, context) + run_metrics_command(args=args, cwd=cwd) + return load_metrics_json(output) From 429e4afb3533bcce236d60d4691d2afbd1abedaf Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:57 +0200 Subject: [PATCH 31/70] 31/70 npm-health: build reusable result snapshots Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 06055db763..65274d911b 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -401,3 +401,16 @@ def collect_external_metrics( args = render_metrics_command(command_template, context) run_metrics_command(args=args, cwd=cwd) return load_metrics_json(output) + + + +def build_snapshot(purl, metadata, metrics, score, collected_at=None): + """Return the persisted npm-health result structure.""" + return { + "purl": purl, + "collected_at": collected_at or datetime.now(UTC).isoformat(), + "score": score, + "classification": classify_health_score(score), + "metrics": normalize_metrics(metrics), + "sources": build_collection_targets(metadata), + } From e1469da557e1c9d2a1ead9226af1f6dce46f7ae3 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:57 +0200 Subject: [PATCH 32/70] 32/70 npm-health: read cached project snapshots Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 65274d911b..1d60935871 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -414,3 +414,11 @@ def build_snapshot(purl, metadata, metrics, score, collected_at=None): "metrics": normalize_metrics(metrics), "sources": build_collection_targets(metadata), } + + + +def get_cached_snapshot(project): + """Return the cached npm-health snapshot from project.extra_data.""" + data = project.extra_data or {} + snapshot = data.get(NPM_HEALTH_EXTRA_DATA_KEY) + return snapshot if isinstance(snapshot, dict) else None From 70a8e8ddc14b589183b33bb02c0b8f7f3170bcf8 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:57 +0200 Subject: [PATCH 33/70] 33/70 npm-health: persist snapshots in project extra data Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 1d60935871..18e4703ac8 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -422,3 +422,10 @@ def get_cached_snapshot(project): data = project.extra_data or {} snapshot = data.get(NPM_HEALTH_EXTRA_DATA_KEY) return snapshot if isinstance(snapshot, dict) else None + + + +def cache_snapshot(project, snapshot): + """Persist one npm-health snapshot in Project.extra_data.""" + project.update_extra_data({NPM_HEALTH_EXTRA_DATA_KEY: snapshot}) + return snapshot From 192a859affcfb6f401161ef6f74df7ca1c3fd982 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:58 +0200 Subject: [PATCH 34/70] 34/70 npm-health: write portable JSON result output Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipes/npm_health.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scanpipe/pipes/npm_health.py b/scanpipe/pipes/npm_health.py index 18e4703ac8..059c297d13 100644 --- a/scanpipe/pipes/npm_health.py +++ b/scanpipe/pipes/npm_health.py @@ -429,3 +429,14 @@ def cache_snapshot(project, snapshot): """Persist one npm-health snapshot in Project.extra_data.""" project.update_extra_data({NPM_HEALTH_EXTRA_DATA_KEY: snapshot}) return snapshot + + + +def write_snapshot(project, snapshot): + """Write one npm-health JSON result into the project output directory.""" + output = project.get_output_file_path("npm-health", "json") + output.write_text( + json.dumps(snapshot, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return output From 82ae20f8817a8c9ef071d97d77685eaf5eeb03de Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:58 +0200 Subject: [PATCH 35/70] 35/70 npm-health pipeline: add pipeline class Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 scanpipe/pipelines/npm_health.py diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py new file mode 100644 index 0000000000..ac42806b37 --- /dev/null +++ b/scanpipe/pipelines/npm_health.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# http://nexb.com and https://github.com/aboutcode-org/scancode.io +# The ScanCode.io software is licensed under the Apache License version 2.0. +# Data generated with ScanCode.io is provided as-is without warranties. +# ScanCode is a trademark of nexB Inc. +# +# You may not use this software except in compliance with the License. +# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Collect and score npm package health information.""" + +from scanpipe.pipelines import Pipeline +from scanpipe.pipes import npm_health + + +class NpmHealth(Pipeline): + """Collect reusable npm package health metrics for one project PURL.""" + + download_inputs = False + is_addon = True + results_url = "/project/{slug}/" From 93bb0baeb14d5823238c60a38e8eea54471980f5 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:58 +0200 Subject: [PATCH 36/70] 36/70 npm-health pipeline: validate project PURL Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index ac42806b37..23a4cd3146 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -24,3 +24,8 @@ class NpmHealth(Pipeline): download_inputs = False is_addon = True results_url = "/project/{slug}/" + + + def validate_project_purl(self): + """Validate and parse the project's versioned npm PURL.""" + self.package = npm_health.validate_npm_package_url(self.project.purl) From 0b6cba6bf1b5de17ec9d85e9a630d203b4920b6d Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:58 +0200 Subject: [PATCH 37/70] 37/70 npm-health pipeline: reuse fresh cached results Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index 23a4cd3146..dc80789010 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -29,3 +29,19 @@ class NpmHealth(Pipeline): def validate_project_purl(self): """Validate and parse the project's versioned npm PURL.""" self.package = npm_health.validate_npm_package_url(self.project.purl) + + + def load_cached_snapshot(self): + """Reuse a fresh project snapshot when one is already available.""" + self.cached_snapshot = None + snapshot = npm_health.get_cached_snapshot(self.project) + max_age = int( + self.env.get( + "npm_health_cache_max_age_days", + npm_health.DEFAULT_CACHE_MAX_AGE_DAYS, + ) + ) + if snapshot and not npm_health.is_stale(snapshot, max_age_days=max_age): + self.cached_snapshot = snapshot + self.snapshot = snapshot + self.log("Using fresh cached npm-health analysis.") From 4a57e173bc5031bc47140ba3a2f3ee0e9f08e442 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:59 +0200 Subject: [PATCH 38/70] 38/70 npm-health pipeline: fetch registry metadata Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index dc80789010..80f9a638aa 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -45,3 +45,10 @@ def load_cached_snapshot(self): self.cached_snapshot = snapshot self.snapshot = snapshot self.log("Using fresh cached npm-health analysis.") + + + def fetch_package_metadata(self): + """Fetch exact npm registry metadata unless a fresh cache is used.""" + if self.cached_snapshot: + return + self.metadata = npm_health.fetch_registry_metadata(self.package) From f3c46527522edc38275b24a4b98847f706d27e17 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:59 +0200 Subject: [PATCH 39/70] 39/70 npm-health pipeline: collect package metrics Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index 80f9a638aa..034c61551b 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -52,3 +52,23 @@ def fetch_package_metadata(self): if self.cached_snapshot: return self.metadata = npm_health.fetch_registry_metadata(self.package) + + + def collect_package_metrics(self): + """Collect built-in registry signals and optional external metrics.""" + if self.cached_snapshot: + return + + baseline = npm_health.collect_registry_metrics(self.metadata) + external = {} + command_template = self.env.get("npm_health_metrics_command") + if command_template: + output = self.project.tmp_path / "npm-health-external-metrics.json" + external = npm_health.collect_external_metrics( + command_template=command_template, + purl=self.project.purl, + metadata=self.metadata, + output=output, + cwd=self.project.tmp_path, + ) + self.metrics = npm_health.merge_metrics(baseline, external) From 7ef6c25a66429de2f6b0a2ef48b455aa69d83c56 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:59 +0200 Subject: [PATCH 40/70] 40/70 npm-health pipeline: compute package score Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index 034c61551b..9be85f2407 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -72,3 +72,11 @@ def collect_package_metrics(self): cwd=self.project.tmp_path, ) self.metrics = npm_health.merge_metrics(baseline, external) + + + def compute_package_health_score(self): + """Compute the normalized package health score.""" + if self.cached_snapshot: + return + weights = self.env.get("npm_health_metric_weights") + self.score = npm_health.compute_health_score(self.metrics, weights=weights) From c98605a900d97fd8472d2e894d68cbe8b08825b9 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:34:59 +0200 Subject: [PATCH 41/70] 41/70 npm-health pipeline: build result snapshot Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index 9be85f2407..505e442ca9 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -80,3 +80,15 @@ def compute_package_health_score(self): return weights = self.env.get("npm_health_metric_weights") self.score = npm_health.compute_health_score(self.metrics, weights=weights) + + + def build_result_snapshot(self): + """Build the reusable snapshot stored on the project.""" + if self.cached_snapshot: + return + self.snapshot = npm_health.build_snapshot( + purl=self.project.purl, + metadata=self.metadata, + metrics=self.metrics, + score=self.score, + ) From 7e7afb37a12bbc3fa9850529f05b8fb60a8444d9 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:00 +0200 Subject: [PATCH 42/70] 42/70 npm-health pipeline: persist package results Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index 505e442ca9..3a9003c650 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -92,3 +92,20 @@ def build_result_snapshot(self): metrics=self.metrics, score=self.score, ) + + + def persist_results(self): + """Persist fresh results to extra_data and a portable JSON output.""" + if self.cached_snapshot: + return + + npm_health.cache_snapshot(self.project, self.snapshot) + output = npm_health.write_snapshot(self.project, self.snapshot) + self.project.add_info( + model="npm_health", + description=( + f"npm-health score: {self.snapshot['score']} " + f"({self.snapshot['classification']})" + ), + details={"output": output.name}, + ) From dad03c9f640de3df68b64423a3fc6b511053dbcf Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:00 +0200 Subject: [PATCH 43/70] 43/70 npm-health pipeline: define execution steps Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/pipelines/npm_health.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scanpipe/pipelines/npm_health.py b/scanpipe/pipelines/npm_health.py index 3a9003c650..94efa25067 100644 --- a/scanpipe/pipelines/npm_health.py +++ b/scanpipe/pipelines/npm_health.py @@ -109,3 +109,16 @@ def persist_results(self): ), details={"output": output.name}, ) + + + @classmethod + def steps(cls): + return ( + cls.validate_project_purl, + cls.load_cached_snapshot, + cls.fetch_package_metadata, + cls.collect_package_metrics, + cls.compute_package_health_score, + cls.build_result_snapshot, + cls.persist_results, + ) From 65d0754caba20eab8b48e2ca5a1ff8bc6b46c3d6 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:00 +0200 Subject: [PATCH 44/70] 44/70 npm-health: register built-in pipeline entry point Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 350c59a0b7..9dbe3bfe47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,6 +158,7 @@ collect_symbols_pygments = "scanpipe.pipelines.collect_symbols_pygments:CollectS collect_symbols_tree_sitter = "scanpipe.pipelines.collect_symbols_tree_sitter:CollectSymbolsTreeSitter" enrich_with_purldb = "scanpipe.pipelines.enrich_with_purldb:EnrichWithPurlDB" fetch_scores = "scanpipe.pipelines.fetch_scores:FetchScores" +npm_health = "scanpipe.pipelines.npm_health:NpmHealth" find_vulnerabilities = "scanpipe.pipelines.find_vulnerabilities:FindVulnerabilities" inspect_elf_binaries = "scanpipe.pipelines.inspect_elf_binaries:InspectELFBinaries" inspect_packages = "scanpipe.pipelines.inspect_packages:InspectPackages" From 4ddcaf08e6cba4780ccea508fa6c11812d0e5c2b Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:00 +0200 Subject: [PATCH 45/70] 45/70 npm-health tests: cover valid npm PURLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 scanpipe/tests/pipes/test_npm_health.py diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py new file mode 100644 index 0000000000..53bce10c30 --- /dev/null +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# http://nexb.com and https://github.com/aboutcode-org/scancode.io +# The ScanCode.io software is licensed under the Apache License version 2.0. +# Data generated with ScanCode.io is provided as-is without warranties. +# ScanCode is a trademark of nexB Inc. +# +# You may not use this software except in compliance with the License. +# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Tests for npm-health helper utilities.""" + +from datetime import UTC +from datetime import datetime +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from django.test import SimpleTestCase + +from scanpipe.pipes import npm_health + + +class NpmHealthPipesTest(SimpleTestCase): + + def test_validate_npm_package_url(self): + package = npm_health.validate_npm_package_url("pkg:npm/lodash@4.17.21") + self.assertEqual("npm", package.type) + self.assertEqual("lodash", package.name) + self.assertEqual("4.17.21", package.version) From c53cce33a2535627b5b1383f3dd7d74ba3cedc6b Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:01 +0200 Subject: [PATCH 46/70] 46/70 npm-health tests: reject missing project PURLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 53bce10c30..57a9ac0ac1 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -32,3 +32,8 @@ def test_validate_npm_package_url(self): self.assertEqual("npm", package.type) self.assertEqual("lodash", package.name) self.assertEqual("4.17.21", package.version) + + + def test_parse_package_url_rejects_empty_value(self): + with self.assertRaises(npm_health.NpmHealthPayloadError): + npm_health.parse_package_url("") From e86fdb3481446cf16438e6ef9404095a3e603518 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:01 +0200 Subject: [PATCH 47/70] 47/70 npm-health tests: reject non npm PURLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 57a9ac0ac1..c0930379e4 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -37,3 +37,8 @@ def test_validate_npm_package_url(self): def test_parse_package_url_rejects_empty_value(self): with self.assertRaises(npm_health.NpmHealthPayloadError): npm_health.parse_package_url("") + + + def test_validate_npm_package_url_rejects_other_types(self): + with self.assertRaises(npm_health.NpmHealthPayloadError): + npm_health.validate_npm_package_url("pkg:pypi/django@5.2") From 256e0e3d01702dcd6a69a8c39f5a41fa633f78b7 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:01 +0200 Subject: [PATCH 48/70] 48/70 npm-health tests: require package versions Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index c0930379e4..971b1c1535 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -42,3 +42,8 @@ def test_parse_package_url_rejects_empty_value(self): def test_validate_npm_package_url_rejects_other_types(self): with self.assertRaises(npm_health.NpmHealthPayloadError): npm_health.validate_npm_package_url("pkg:pypi/django@5.2") + + + def test_validate_npm_package_url_requires_version(self): + with self.assertRaises(npm_health.NpmHealthPayloadError): + npm_health.validate_npm_package_url("pkg:npm/lodash") From db1357c55198e38952cd9bc03e8741dde7f250fc Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:02 +0200 Subject: [PATCH 49/70] 49/70 npm-health tests: support scoped package names Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 971b1c1535..bca6779ef8 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -47,3 +47,10 @@ def test_validate_npm_package_url_rejects_other_types(self): def test_validate_npm_package_url_requires_version(self): with self.assertRaises(npm_health.NpmHealthPayloadError): npm_health.validate_npm_package_url("pkg:npm/lodash") + + + def test_get_package_name_supports_scopes(self): + package = npm_health.validate_npm_package_url( + "pkg:npm/%40babel/core@7.28.0" + ) + self.assertEqual("@babel/core", npm_health.get_package_name(package)) From fff432b57be6d48d7753aa1652858404a417fecf Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:02 +0200 Subject: [PATCH 50/70] 50/70 npm-health tests: build scoped registry URLs Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index bca6779ef8..02714ebc3d 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -54,3 +54,13 @@ def test_get_package_name_supports_scopes(self): "pkg:npm/%40babel/core@7.28.0" ) self.assertEqual("@babel/core", npm_health.get_package_name(package)) + + + def test_get_registry_metadata_url_for_scoped_package(self): + package = npm_health.validate_npm_package_url( + "pkg:npm/%40babel/core@7.28.0" + ) + self.assertEqual( + "https://registry.npmjs.org/@babel%2Fcore/7.28.0", + npm_health.get_registry_metadata_url(package), + ) From 1f78d3d79803593b8b7bb08b371a665b42d23d58 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:02 +0200 Subject: [PATCH 51/70] 51/70 npm-health tests: normalize repository dictionaries Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 02714ebc3d..d1edb68250 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -64,3 +64,11 @@ def test_get_registry_metadata_url_for_scoped_package(self): "https://registry.npmjs.org/@babel%2Fcore/7.28.0", npm_health.get_registry_metadata_url(package), ) + + + def test_normalize_repository_url_dict(self): + repository = {"type": "git", "url": "git+https://github.com/a/b.git"} + self.assertEqual( + "https://github.com/a/b", + npm_health.normalize_repository_url(repository), + ) From e91a72002a02c1659bffecf8f3eaee3ea7b8847c Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:02 +0200 Subject: [PATCH 52/70] 52/70 npm-health tests: normalize GitHub git transports Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index d1edb68250..2fe3b671c8 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -72,3 +72,14 @@ def test_normalize_repository_url_dict(self): "https://github.com/a/b", npm_health.normalize_repository_url(repository), ) + + + def test_normalize_repository_url_git_transports(self): + self.assertEqual( + "https://github.com/a/b", + npm_health.normalize_repository_url("git://github.com/a/b.git"), + ) + self.assertEqual( + "https://github.com/a/b", + npm_health.normalize_repository_url("git@github.com:a/b.git"), + ) From 35be255bd1fd09b362d55e46d5d911502fc85070 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:03 +0200 Subject: [PATCH 53/70] 53/70 npm-health tests: expose registry collection targets Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 2fe3b671c8..82514039ab 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -83,3 +83,19 @@ def test_normalize_repository_url_git_transports(self): "https://github.com/a/b", npm_health.normalize_repository_url("git@github.com:a/b.git"), ) + + + def test_build_collection_targets(self): + metadata = { + "repository": {"url": "https://github.com/a/b.git"}, + "homepage": "https://example.com", + "dist": {"tarball": "https://registry.example/a.tgz"}, + } + self.assertEqual( + { + "repository_url": "https://github.com/a/b", + "tarball_url": "https://registry.example/a.tgz", + "homepage_url": "https://example.com", + }, + npm_health.build_collection_targets(metadata), + ) From 806184f0cb0f0bc4a6b0236a2ccd1cf52e54cd20 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:03 +0200 Subject: [PATCH 54/70] 54/70 npm-health tests: normalize fractional metrics Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 82514039ab..97a9a83e8d 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -99,3 +99,7 @@ def test_build_collection_targets(self): }, npm_health.build_collection_targets(metadata), ) + + + def test_normalize_metric_value_fraction(self): + self.assertEqual(0.75, npm_health.normalize_metric_value(0.75)) From a6a552f3d885b5874c08afdf79f655b64d20f424 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:03 +0200 Subject: [PATCH 55/70] 55/70 npm-health tests: normalize percentage metrics Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 97a9a83e8d..bda5590f3c 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -103,3 +103,8 @@ def test_build_collection_targets(self): def test_normalize_metric_value_fraction(self): self.assertEqual(0.75, npm_health.normalize_metric_value(0.75)) + + + def test_normalize_metric_value_percentage(self): + self.assertEqual(0.75, npm_health.normalize_metric_value(75)) + self.assertEqual(1.0, npm_health.normalize_metric_value(150)) From c36f9f9c1baad387c264a2063d81caa4b03719f3 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:04 +0200 Subject: [PATCH 56/70] 56/70 npm-health tests: normalize nested collector payloads Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index bda5590f3c..6bc56ee823 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -108,3 +108,12 @@ def test_normalize_metric_value_fraction(self): def test_normalize_metric_value_percentage(self): self.assertEqual(0.75, npm_health.normalize_metric_value(75)) self.assertEqual(1.0, npm_health.normalize_metric_value(150)) + + + def test_normalize_metrics_nested_payload(self): + self.assertEqual( + {"activity": 0.8, "security": 1.0}, + npm_health.normalize_metrics( + {"metrics": {"activity": 80, "security": True}} + ), + ) From 73a57d1326945622d4411946a8f116e3cfad4052 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:04 +0200 Subject: [PATCH 57/70] 57/70 npm-health tests: derive registry metrics Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 6bc56ee823..fb8024c4a7 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -117,3 +117,17 @@ def test_normalize_metrics_nested_payload(self): {"metrics": {"activity": 80, "security": True}} ), ) + + + def test_collect_registry_metrics(self): + metadata = { + "repository": {"url": "https://github.com/a/b.git"}, + "homepage": "https://example.com", + "license": "MIT", + "maintainers": [{"name": "a"}, {"name": "b"}, {"name": "c"}], + "dependencies": {"one": "1", "two": "2"}, + } + metrics = npm_health.collect_registry_metrics(metadata) + self.assertEqual(1.0, metrics["metadata_completeness"]) + self.assertEqual(1.0, metrics["maintainer_presence"]) + self.assertGreater(metrics["dependency_simplicity"], 0.9) From b1170732752d55b5aa6d2fc7cc229b238fe6ddc7 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:04 +0200 Subject: [PATCH 58/70] 58/70 npm-health tests: merge external metrics Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index fb8024c4a7..6f8d379d62 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -131,3 +131,11 @@ def test_collect_registry_metrics(self): self.assertEqual(1.0, metrics["metadata_completeness"]) self.assertEqual(1.0, metrics["maintainer_presence"]) self.assertGreater(metrics["dependency_simplicity"], 0.9) + + + def test_merge_metrics_external_values_override_baseline(self): + merged = npm_health.merge_metrics( + {"activity": 0.2, "security": 0.5}, + {"activity": 90}, + ) + self.assertEqual({"activity": 0.9, "security": 0.5}, merged) From e5b6eaa1c49639f27ad828fb21d6334be960a4eb Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:04 +0200 Subject: [PATCH 59/70] 59/70 npm-health tests: normalize positive scoring weights Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 6f8d379d62..57daf9b770 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -139,3 +139,10 @@ def test_merge_metrics_external_values_override_baseline(self): {"activity": 90}, ) self.assertEqual({"activity": 0.9, "security": 0.5}, merged) + + + def test_normalize_weights_ignores_non_positive_values(self): + self.assertEqual( + {"activity": 2.0}, + npm_health.normalize_weights({"activity": 2, "security": 0}), + ) From b67c2c4b06fefb62d55e02a992a29dfed0a3ff49 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:05 +0200 Subject: [PATCH 60/70] 60/70 npm-health tests: compute weighted scores Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 57daf9b770..861581ed27 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -146,3 +146,11 @@ def test_normalize_weights_ignores_non_positive_values(self): {"activity": 2.0}, npm_health.normalize_weights({"activity": 2, "security": 0}), ) + + + def test_compute_health_score(self): + score = npm_health.compute_health_score( + {"activity": 1.0, "security": 0.5}, + {"activity": 1.0, "security": 1.0}, + ) + self.assertEqual(75.0, score) From 2303e06eef5a143cc90c5f2f585f52aa5b7ea033 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:05 +0200 Subject: [PATCH 61/70] 61/70 npm-health tests: classify health scores Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 861581ed27..25a6c892aa 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -154,3 +154,10 @@ def test_compute_health_score(self): {"activity": 1.0, "security": 1.0}, ) self.assertEqual(75.0, score) + + + def test_classify_health_score(self): + self.assertEqual("excellent", npm_health.classify_health_score(90)) + self.assertEqual("good", npm_health.classify_health_score(70)) + self.assertEqual("needs-attention", npm_health.classify_health_score(50)) + self.assertEqual("high-risk", npm_health.classify_health_score(20)) From 2efc1f32e6feefa3aa8cea6b2b0ab1fc1f5daff5 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:05 +0200 Subject: [PATCH 62/70] 62/70 npm-health tests: keep fresh cached analyses Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 25a6c892aa..3409171a7b 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -161,3 +161,9 @@ def test_classify_health_score(self): self.assertEqual("good", npm_health.classify_health_score(70)) self.assertEqual("needs-attention", npm_health.classify_health_score(50)) self.assertEqual("high-risk", npm_health.classify_health_score(20)) + + + def test_is_stale_keeps_fresh_snapshot(self): + now = datetime(2026, 8, 17, tzinfo=UTC) + snapshot = {"collected_at": "2026-08-16T00:00:00+00:00"} + self.assertFalse(npm_health.is_stale(snapshot, max_age_days=90, now=now)) From 0fc3656f0881a0c7000427b4d2cc48ba92cbc326 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:06 +0200 Subject: [PATCH 63/70] 63/70 npm-health tests: expire stale cached analyses Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 3409171a7b..7c17b8af68 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -167,3 +167,9 @@ def test_is_stale_keeps_fresh_snapshot(self): now = datetime(2026, 8, 17, tzinfo=UTC) snapshot = {"collected_at": "2026-08-16T00:00:00+00:00"} self.assertFalse(npm_health.is_stale(snapshot, max_age_days=90, now=now)) + + + def test_is_stale_expires_old_snapshot(self): + now = datetime(2026, 8, 17, tzinfo=UTC) + snapshot = {"collected_at": "2026-01-01T00:00:00+00:00"} + self.assertTrue(npm_health.is_stale(snapshot, max_age_days=90, now=now)) From 724cc74ca1337830233de04dd5439e7fcd43556b Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:06 +0200 Subject: [PATCH 64/70] 64/70 npm-health tests: render collector commands without a shell Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 7c17b8af68..23129eacfd 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -173,3 +173,26 @@ def test_is_stale_expires_old_snapshot(self): now = datetime(2026, 8, 17, tzinfo=UTC) snapshot = {"collected_at": "2026-01-01T00:00:00+00:00"} self.assertTrue(npm_health.is_stale(snapshot, max_age_days=90, now=now)) + + + def test_render_metrics_command(self): + context = { + "purl": "pkg:npm/lodash@4.17.21", + "repository_url": "https://github.com/lodash/lodash", + "tarball_url": "https://example.com/lodash.tgz", + "output": "/tmp/result.json", + } + args = npm_health.render_metrics_command( + "collector --repo {repository_url} --output {output}", + context, + ) + self.assertEqual( + [ + "collector", + "--repo", + "https://github.com/lodash/lodash", + "--output", + "/tmp/result.json", + ], + args, + ) From e65bebb74a08c90ddf3a66545e754ea09cc713ba Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:06 +0200 Subject: [PATCH 65/70] 65/70 npm-health tests: reject invalid collector JSON Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 23129eacfd..6da21fa574 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -196,3 +196,11 @@ def test_render_metrics_command(self): ], args, ) + + + def test_load_metrics_json_rejects_invalid_json(self): + with TemporaryDirectory() as temp_dir: + location = Path(temp_dir) / "metrics.json" + location.write_text("{", encoding="utf-8") + with self.assertRaises(npm_health.NpmHealthPayloadError): + npm_health.load_metrics_json(location) From 89eb9f0f254adcd6f7595eef03e817bc7d44266c Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:06 +0200 Subject: [PATCH 66/70] 66/70 npm-health tests: read cached snapshots Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index 6da21fa574..cc78d8a7fc 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -204,3 +204,8 @@ def test_load_metrics_json_rejects_invalid_json(self): location.write_text("{", encoding="utf-8") with self.assertRaises(npm_health.NpmHealthPayloadError): npm_health.load_metrics_json(location) + + + def test_get_cached_snapshot(self): + project = mock.Mock(extra_data={"npm_health": {"score": 88}}) + self.assertEqual({"score": 88}, npm_health.get_cached_snapshot(project)) From 8bdbe9225c8d0aef2a3fc4b291cc648329b32066 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:07 +0200 Subject: [PATCH 67/70] 67/70 npm-health tests: persist project snapshots Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/pipes/test_npm_health.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scanpipe/tests/pipes/test_npm_health.py b/scanpipe/tests/pipes/test_npm_health.py index cc78d8a7fc..63766dec9a 100644 --- a/scanpipe/tests/pipes/test_npm_health.py +++ b/scanpipe/tests/pipes/test_npm_health.py @@ -209,3 +209,12 @@ def test_load_metrics_json_rejects_invalid_json(self): def test_get_cached_snapshot(self): project = mock.Mock(extra_data={"npm_health": {"score": 88}}) self.assertEqual({"score": 88}, npm_health.get_cached_snapshot(project)) + + + def test_cache_snapshot(self): + project = mock.Mock() + snapshot = {"purl": "pkg:npm/lodash@4.17.21", "score": 88} + self.assertEqual(snapshot, npm_health.cache_snapshot(project, snapshot)) + project.update_extra_data.assert_called_once_with( + {npm_health.NPM_HEALTH_EXTRA_DATA_KEY: snapshot} + ) From 5aa038d5f2eb1d35e3192b9e34641867c3a07231 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:07 +0200 Subject: [PATCH 68/70] 68/70 npm-health tests: cover pipeline execution contract Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- scanpipe/tests/test_npm_health_pipeline.py | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 scanpipe/tests/test_npm_health_pipeline.py diff --git a/scanpipe/tests/test_npm_health_pipeline.py b/scanpipe/tests/test_npm_health_pipeline.py new file mode 100644 index 0000000000..65f8ac94a5 --- /dev/null +++ b/scanpipe/tests/test_npm_health_pipeline.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# http://nexb.com and https://github.com/aboutcode-org/scancode.io +# The ScanCode.io software is licensed under the Apache License version 2.0. +# Data generated with ScanCode.io is provided as-is without warranties. +# ScanCode is a trademark of nexB Inc. +# +# You may not use this software except in compliance with the License. +# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Tests for the npm-health pipeline definition.""" + +from django.test import SimpleTestCase + +from scanpipe.pipelines.npm_health import NpmHealth + + +class NpmHealthPipelineTest(SimpleTestCase): + def test_pipeline_flags_and_results_url(self): + self.assertFalse(NpmHealth.download_inputs) + self.assertTrue(NpmHealth.is_addon) + self.assertEqual("/project/{slug}/", NpmHealth.results_url) + + def test_pipeline_steps(self): + self.assertEqual( + [ + "validate_project_purl", + "load_cached_snapshot", + "fetch_package_metadata", + "collect_package_metrics", + "compute_package_health_score", + "build_result_snapshot", + "persist_results", + ], + [step.__name__ for step in NpmHealth.steps()], + ) From 1de3b4595bf403b3e5cfd2561560d383832563b1 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:07 +0200 Subject: [PATCH 69/70] 69/70 docs: add npm-health built-in pipeline reference Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- docs/built-in-pipelines.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/built-in-pipelines.rst b/docs/built-in-pipelines.rst index 13f8ffda71..ec7e265c9e 100644 --- a/docs/built-in-pipelines.rst +++ b/docs/built-in-pipelines.rst @@ -282,3 +282,18 @@ Fetch Scores (addon) .. autoclass:: scanpipe.pipelines.fetch_scores.FetchScores() :members: :member-order: bysource + + +.. _pipeline_npm_health: + +NPM Health (addon) +------------------ + +The ``npm_health`` pipeline analyzes one exact versioned npm ``Project.purl``. +It retrieves npm registry metadata, records repository and package tarball +locations, normalizes health signals, computes a weighted score from 0 to 100, +and stores a reusable snapshot in ``Project.extra_data["npm_health"]``. + +.. autoclass:: scanpipe.pipelines.npm_health.NpmHealth() + :members: + :member-order: bysource From 3add89631c5596eb9be81ad0bd148e2896a47006 Mon Sep 17 00:00:00 2001 From: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:08 +0200 Subject: [PATCH 70/70] 70/70 docs: document npm-health adapter and output Signed-off-by: Luca Magrini <89993099+Berserk-hub150@users.noreply.github.com> --- docs/built-in-pipelines.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/built-in-pipelines.rst b/docs/built-in-pipelines.rst index ec7e265c9e..67c42d26f4 100644 --- a/docs/built-in-pipelines.rst +++ b/docs/built-in-pipelines.rst @@ -297,3 +297,21 @@ and stores a reusable snapshot in ``Project.extra_data["npm_health"]``. .. autoclass:: scanpipe.pipelines.npm_health.NpmHealth() :members: :member-order: bysource + + +NPM Health configuration +~~~~~~~~~~~~~~~~~~~~~~~~ + +Fresh snapshots are reused for 90 days by default. Override the age with +``npm_health_cache_max_age_days`` in project pipeline settings. + +Optional GrimoireLab or other project-health metrics can be connected through +``npm_health_metrics_command``. The command is parsed to an argument list and +executed without a shell. It may use ``{purl}``, ``{repository_url}``, +``{tarball_url}``, and ``{output}`` placeholders. The collector must write JSON +metrics to ``{output}``, either directly or under a top-level ``metrics`` key. +Metric values may be fractions from 0 to 1 or percentages from 0 to 100. + +The optional ``npm_health_metric_weights`` mapping customizes scoring weights. +Only metrics present in the current analysis participate in the denominator. +The pipeline also writes a timestamped ``npm-health-*.json`` project output.