From 7a7334893eaf79044b90c233fe1bc0b5fa894313 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:16:44 +0000 Subject: [PATCH 01/11] Initial plan From 5da2ef07373e0a80c8620c561fde4bc8cbf5b1df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:23:29 +0000 Subject: [PATCH 02/11] Add changelog command group to azpysdk CLI wrapping Chronus Adds `azpysdk changelog {add,verify,create,status}` subcommands that delegate to `npx chronus` at the repository root, enabling developers to manage changelogs without leaving the azpysdk CLI. Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/a188ee1e-f36a-4ca9-be34-654196dd5328 Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> --- .../azure-sdk-tools/azpysdk/changelog.py | 124 ++++++++++++++ eng/tools/azure-sdk-tools/azpysdk/main.py | 2 + .../tests/test_changelog_commands.py | 160 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 eng/tools/azure-sdk-tools/azpysdk/changelog.py create mode 100644 eng/tools/azure-sdk-tools/tests/test_changelog_commands.py diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py new file mode 100644 index 000000000000..63f0d30c4783 --- /dev/null +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -0,0 +1,124 @@ +import argparse +import os +import shutil +import subprocess +from typing import Optional, List + +from .Check import Check, REPO_ROOT +from ci_tools.logging import logger + + +class changelog(Check): + """Manage changelogs with Chronus. + + Wraps Chronus CLI commands (add, verify, create, status) so they can be + invoked through the ``azpysdk`` CLI. Unlike most checks that operate on + individual packages, changelog commands run at the **repository root** + level via ``npx chronus``. + """ + + def __init__(self) -> None: + super().__init__() + + def register( + self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None + ) -> None: + """Register the ``changelog`` command group. + + The *parent_parsers* (common args like ``target``, ``--isolate``) are + intentionally **not** used here because changelog commands operate at + the repository level via Chronus, not on individual packages. + """ + p = subparsers.add_parser( + "changelog", + help="Manage changelogs with Chronus (add, verify, create, status)", + ) + + changelog_sub = p.add_subparsers(title="changelog commands", dest="changelog_command") + + # changelog add + add_p = changelog_sub.add_parser("add", help="Add a chronus change entry for modified packages") + add_p.add_argument( + "package", + nargs="?", + default=None, + help=( + "Package path (e.g. sdk/storage/azure-storage-blob) to add an entry for. " + "If omitted, chronus detects modified packages interactively." + ), + ) + add_p.set_defaults(func=self._run_add) + + # changelog verify + verify_p = changelog_sub.add_parser("verify", help="Verify all modified packages have change entries") + verify_p.set_defaults(func=self._run_verify) + + # changelog create + create_p = changelog_sub.add_parser( + "create", help="Generate CHANGELOG.md from pending chronus entries" + ) + create_p.set_defaults(func=self._run_create) + + # changelog status + status_p = changelog_sub.add_parser( + "status", help="Show a summary of pending changes and resulting version bumps" + ) + status_p.set_defaults(func=self._run_status) + + # Default behaviour when no subcommand is given + p.set_defaults(func=self._no_subcommand, _changelog_parser=p) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _no_subcommand(self, args: argparse.Namespace) -> int: + """Print help when no changelog subcommand is provided.""" + args._changelog_parser.print_help() + return 1 + + def _get_npx(self) -> str: + """Locate the ``npx`` executable on *PATH*.""" + npx = shutil.which("npx") + if not npx: + logger.error( + "npx is not installed. Chronus requires Node.js. " + "Please install Node.js (LTS) from https://nodejs.org/ and try again." + ) + raise FileNotFoundError("npx not found on PATH") + return npx + + def _run_chronus(self, chronus_args: List[str]) -> int: + """Run a chronus command via ``npx`` from the repository root. + + stdin/stdout/stderr are inherited so that interactive prompts + (e.g. ``chronus add``) work transparently. + """ + npx = self._get_npx() + cmd = [npx, "chronus"] + chronus_args + logger.info(f"Running: {' '.join(cmd)}") + return subprocess.call(cmd, cwd=REPO_ROOT) + + # ------------------------------------------------------------------ + # Subcommand handlers + # ------------------------------------------------------------------ + + def _run_add(self, args: argparse.Namespace) -> int: + """Run ``chronus add`` to interactively add a change entry.""" + chronus_args = ["add"] + package = getattr(args, "package", None) + if package: + chronus_args.append(package) + return self._run_chronus(chronus_args) + + def _run_verify(self, args: argparse.Namespace) -> int: + """Run ``chronus verify`` to check for missing change entries.""" + return self._run_chronus(["verify"]) + + def _run_create(self, args: argparse.Namespace) -> int: + """Run ``chronus changelog`` to generate CHANGELOG.md files.""" + return self._run_chronus(["changelog"]) + + def _run_status(self, args: argparse.Namespace) -> int: + """Run ``chronus status`` to show pending changes.""" + return self._run_chronus(["status"]) diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index 00f03d5fd9c1..9cb8e1358eca 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -41,6 +41,7 @@ from .devtest import devtest from .optional import optional from .update_snippet import update_snippet +from .changelog import changelog from ci_tools.logging import configure_logging, logger @@ -141,6 +142,7 @@ def build_parser() -> argparse.ArgumentParser: devtest().register(subparsers, [common]) optional().register(subparsers, [common]) update_snippet().register(subparsers, [common]) + changelog().register(subparsers, [common]) return parser diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py new file mode 100644 index 000000000000..aea8adcca029 --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -0,0 +1,160 @@ +"""Tests for the ``azpysdk changelog`` command group.""" + +import argparse +from unittest.mock import patch, MagicMock + +import pytest + +from azpysdk.changelog import changelog + + +# --------------------------------------------------------------------------- +# Helper – build a minimal parser that includes the changelog subcommands +# --------------------------------------------------------------------------- + +def _build_parser(): + parser = argparse.ArgumentParser(prog="azpysdk") + subparsers = parser.add_subparsers(title="commands", dest="command") + changelog().register(subparsers) + return parser + + +# --------------------------------------------------------------------------- +# Parser / registration tests +# --------------------------------------------------------------------------- + + +class TestChangelogRegistration: + """Verify that the changelog command is registered correctly.""" + + def test_changelog_subcommand_exists(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "verify"]) + assert args.command == "changelog" + assert args.changelog_command == "verify" + + def test_changelog_add_without_package(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "add"]) + assert args.changelog_command == "add" + assert args.package is None + + def test_changelog_add_with_package(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "sdk/storage/azure-storage-blob"]) + assert args.changelog_command == "add" + assert args.package == "sdk/storage/azure-storage-blob" + + def test_changelog_create(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "create"]) + assert args.changelog_command == "create" + + def test_changelog_status(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "status"]) + assert args.changelog_command == "status" + + def test_changelog_no_subcommand_prints_help(self): + """When no subcommand is given, the handler should print help and return 1.""" + parser = _build_parser() + args = parser.parse_args(["changelog"]) + assert hasattr(args, "func") + # func should be the _no_subcommand method + result = args.func(args) + assert result == 1 + + +# --------------------------------------------------------------------------- +# Execution tests (npx / chronus invocation) +# --------------------------------------------------------------------------- + + +class TestChangelogExecution: + """Verify that each subcommand invokes chronus with the right arguments.""" + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_calls_chronus_add(self, mock_which, mock_call): + parser = _build_parser() + args = parser.parse_args(["changelog", "add"]) + result = args.func(args) + assert result == 0 + mock_call.assert_called_once() + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "add"] + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_with_package_passes_package(self, mock_which, mock_call): + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "sdk/core/azure-core"]) + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/core/azure-core"] + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_verify_calls_chronus_verify(self, mock_which, mock_call): + parser = _build_parser() + args = parser.parse_args(["changelog", "verify"]) + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "verify"] + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_create_calls_chronus_changelog(self, mock_which, mock_call): + parser = _build_parser() + args = parser.parse_args(["changelog", "create"]) + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "changelog"] + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_status_calls_chronus_status(self, mock_which, mock_call): + parser = _build_parser() + args = parser.parse_args(["changelog", "status"]) + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "status"] + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_chronus_runs_from_repo_root(self, mock_which, mock_call): + """Chronus must run from the repository root directory.""" + parser = _build_parser() + args = parser.parse_args(["changelog", "verify"]) + args.func(args) + _, kwargs = mock_call.call_args + from azpysdk.changelog import REPO_ROOT + assert kwargs["cwd"] == REPO_ROOT + + @patch("azpysdk.changelog.subprocess.call", return_value=1) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_nonzero_exit_code_propagated(self, mock_which, mock_call): + parser = _build_parser() + args = parser.parse_args(["changelog", "verify"]) + result = args.func(args) + assert result == 1 + + +# --------------------------------------------------------------------------- +# Error handling tests +# --------------------------------------------------------------------------- + + +class TestChangelogErrors: + """Verify error handling when prerequisites are missing.""" + + @patch("azpysdk.changelog.shutil.which", return_value=None) + def test_npx_not_found_raises(self, mock_which): + parser = _build_parser() + args = parser.parse_args(["changelog", "verify"]) + with pytest.raises(FileNotFoundError, match="npx not found"): + args.func(args) From 03bb72f1d196e203896fcc118a433b8336fa7230 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:44:35 +0000 Subject: [PATCH 03/11] Detect package from CWD so changelog commands work from package dirs When running `azpysdk changelog add` from within a package directory (e.g. sdk/storage/azure-storage-blob), the package path is now detected automatically and passed to chronus. An explicit package argument still takes precedence. Chronus itself always runs from the repo root. Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/758fb61b-770f-40c1-91a6-108220c6a402 Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> --- .../azure-sdk-tools/azpysdk/changelog.py | 43 ++++++++-- .../tests/test_changelog_commands.py | 86 ++++++++++++++++++- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index 63f0d30c4783..f53a5e725abe 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -12,9 +12,9 @@ class changelog(Check): """Manage changelogs with Chronus. Wraps Chronus CLI commands (add, verify, create, status) so they can be - invoked through the ``azpysdk`` CLI. Unlike most checks that operate on - individual packages, changelog commands run at the **repository root** - level via ``npx chronus``. + invoked through the ``azpysdk`` CLI. Commands can be run from the + repository root **or** from within a package directory — the tool will + detect the package path automatically when possible. """ def __init__(self) -> None: @@ -44,7 +44,8 @@ def register( default=None, help=( "Package path (e.g. sdk/storage/azure-storage-blob) to add an entry for. " - "If omitted, chronus detects modified packages interactively." + "If omitted and CWD is inside a package directory, the package is detected " + "automatically. Otherwise chronus detects modified packages interactively." ), ) add_p.set_defaults(func=self._run_add) @@ -88,6 +89,29 @@ def _get_npx(self) -> str: raise FileNotFoundError("npx not found on PATH") return npx + def _detect_package_from_cwd(self) -> Optional[str]: + """If CWD is inside a package directory (``sdk//``), + return the relative path from the repository root. Otherwise return + ``None``. + + The chronus config uses the pattern ``sdk/*/*`` for packages, so we + look for CWD being at or below ``/sdk//``. + """ + try: + cwd = os.path.abspath(os.getcwd()) + repo = os.path.abspath(REPO_ROOT) + rel = os.path.relpath(cwd, repo) + except ValueError: + # On Windows, relpath raises ValueError when paths are on different drives + return None + + # rel should start with "sdk//" (at least 3 components) + parts = rel.replace("\\", "/").split("/") + if len(parts) >= 3 and parts[0] == "sdk": + # Return the first 3 components: sdk// + return "/".join(parts[:3]) + return None + def _run_chronus(self, chronus_args: List[str]) -> int: """Run a chronus command via ``npx`` from the repository root. @@ -104,9 +128,18 @@ def _run_chronus(self, chronus_args: List[str]) -> int: # ------------------------------------------------------------------ def _run_add(self, args: argparse.Namespace) -> int: - """Run ``chronus add`` to interactively add a change entry.""" + """Run ``chronus add`` to interactively add a change entry. + + When no *package* argument is given but CWD is inside a package + directory (``sdk//``), the package path is detected + automatically so the developer doesn't have to specify it. + """ chronus_args = ["add"] package = getattr(args, "package", None) + if not package: + package = self._detect_package_from_cwd() + if package: + logger.info(f"Detected package from current directory: {package}") if package: chronus_args.append(package) return self._run_chronus(chronus_args) diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index aea8adcca029..d3205f720ce2 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -1,11 +1,12 @@ """Tests for the ``azpysdk changelog`` command group.""" import argparse +import os from unittest.mock import patch, MagicMock import pytest -from azpysdk.changelog import changelog +from azpysdk.changelog import changelog, REPO_ROOT # --------------------------------------------------------------------------- @@ -78,7 +79,9 @@ class TestChangelogExecution: def test_add_calls_chronus_add(self, mock_which, mock_call): parser = _build_parser() args = parser.parse_args(["changelog", "add"]) - result = args.func(args) + # Run from repo root so CWD detection does NOT inject a package + with patch("os.getcwd", return_value=REPO_ROOT): + result = args.func(args) assert result == 0 mock_call.assert_called_once() cmd = mock_call.call_args[0][0] @@ -94,6 +97,45 @@ def test_add_with_package_passes_package(self, mock_which, mock_call): cmd = mock_call.call_args[0][0] assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/core/azure-core"] + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_detects_package_from_cwd(self, mock_which, mock_call): + """When CWD is inside a package dir and no package arg is given, detect it.""" + parser = _build_parser() + args = parser.parse_args(["changelog", "add"]) + pkg_dir = os.path.join(REPO_ROOT, "sdk", "storage", "azure-storage-blob") + with patch("os.getcwd", return_value=pkg_dir): + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/storage/azure-storage-blob"] + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_detects_package_from_subdirectory(self, mock_which, mock_call): + """When CWD is a subdirectory of a package, detect the package root.""" + parser = _build_parser() + args = parser.parse_args(["changelog", "add"]) + sub_dir = os.path.join(REPO_ROOT, "sdk", "storage", "azure-storage-blob", "azure", "storage", "blob") + with patch("os.getcwd", return_value=sub_dir): + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/storage/azure-storage-blob"] + + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call): + """An explicit package argument takes precedence over CWD detection.""" + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "sdk/core/azure-core"]) + pkg_dir = os.path.join(REPO_ROOT, "sdk", "storage", "azure-storage-blob") + with patch("os.getcwd", return_value=pkg_dir): + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/core/azure-core"] + @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") def test_verify_calls_chronus_verify(self, mock_which, mock_call): @@ -132,7 +174,6 @@ def test_chronus_runs_from_repo_root(self, mock_which, mock_call): args = parser.parse_args(["changelog", "verify"]) args.func(args) _, kwargs = mock_call.call_args - from azpysdk.changelog import REPO_ROOT assert kwargs["cwd"] == REPO_ROOT @patch("azpysdk.changelog.subprocess.call", return_value=1) @@ -144,6 +185,45 @@ def test_nonzero_exit_code_propagated(self, mock_which, mock_call): assert result == 1 +# --------------------------------------------------------------------------- +# CWD detection unit tests +# --------------------------------------------------------------------------- + + +class TestDetectPackageFromCwd: + """Test the _detect_package_from_cwd helper directly.""" + + def test_at_package_root(self): + c = changelog() + with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "core", "azure-core")): + assert c._detect_package_from_cwd() == "sdk/core/azure-core" + + def test_inside_package_subdir(self): + c = changelog() + with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "core", "azure-core", "azure", "core")): + assert c._detect_package_from_cwd() == "sdk/core/azure-core" + + def test_at_repo_root(self): + c = changelog() + with patch("os.getcwd", return_value=REPO_ROOT): + assert c._detect_package_from_cwd() is None + + def test_at_sdk_dir(self): + c = changelog() + with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk")): + assert c._detect_package_from_cwd() is None + + def test_at_service_dir(self): + c = changelog() + with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "storage")): + assert c._detect_package_from_cwd() is None + + def test_outside_repo(self): + c = changelog() + with patch("os.getcwd", return_value="/tmp"): + assert c._detect_package_from_cwd() is None + + # --------------------------------------------------------------------------- # Error handling tests # --------------------------------------------------------------------------- From 62bc4285c24a0ac9cf0ec1afa407441665c2b043 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 7 Apr 2026 09:43:35 -0700 Subject: [PATCH 04/11] nice install of chronus --- .../azure-sdk-tools/azpysdk/changelog.py | 63 +++++++- .../tests/test_changelog_commands.py | 151 +++++++++++++++--- 2 files changed, 191 insertions(+), 23 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index f53a5e725abe..b06b42bfc76b 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -2,11 +2,16 @@ import os import shutil import subprocess +import sys from typing import Optional, List from .Check import Check, REPO_ROOT from ci_tools.logging import logger +# The expected Chronus package name in node_modules +_CHRONUS_PACKAGE = "@chronus/chronus" +_CHRONUS_MODULE_PATH = os.path.join("node_modules", "@chronus", "chronus") + class changelog(Check): """Manage changelogs with Chronus. @@ -89,6 +94,57 @@ def _get_npx(self) -> str: raise FileNotFoundError("npx not found on PATH") return npx + def _is_chronus_installed(self) -> bool: + """Return ``True`` if Chronus is installed locally in *node_modules*.""" + return os.path.isdir(os.path.join(REPO_ROOT, _CHRONUS_MODULE_PATH)) + + def _ensure_chronus_installed(self) -> None: + """Verify that Chronus is installed locally, offering to install if not. + + Security: we **never** allow ``npx`` to silently download packages + from the npm registry. Instead we check for a local installation + and, when missing, run ``npm install`` against the repo-root + ``package.json`` so only declared dependencies are resolved. + + Raises ``SystemExit`` if the user declines or installation fails. + """ + if self._is_chronus_installed(): + return + + npm = shutil.which("npm") + if not npm: + logger.error( + "Chronus is not installed and npm was not found on PATH.\n" + "Please install Node.js (LTS) from https://nodejs.org/ then run:\n\n" + f" cd {REPO_ROOT}\n" + " npm install\n" + ) + raise SystemExit(1) + + + print( + "\nChronus is not installed locally. It is listed as a dev dependency\n" + f"in {os.path.join(REPO_ROOT, 'package.json')}.\n" + ) + answer = input("Run 'npm install' in the repo root to install it? [Y/n] ").strip().lower() + if answer not in ("", "y", "yes"): + logger.info("Skipped Chronus installation.") + raise SystemExit(1) + + logger.info(f"Running: npm install (cwd: {REPO_ROOT})") + rc = subprocess.call([npm, "install"], cwd=REPO_ROOT) + if rc != 0: + logger.error("'npm install' failed. Please resolve npm errors and try again.") + raise SystemExit(rc) + + if not self._is_chronus_installed(): + logger.error( + "'npm install' succeeded but Chronus was not found in node_modules.\n" + f"Expected: {os.path.join(REPO_ROOT, _CHRONUS_MODULE_PATH)}\n" + "Please verify that package.json lists @chronus/chronus as a dependency." + ) + raise SystemExit(1) + def _detect_package_from_cwd(self) -> Optional[str]: """If CWD is inside a package directory (``sdk//``), return the relative path from the repository root. Otherwise return @@ -115,11 +171,16 @@ def _detect_package_from_cwd(self) -> Optional[str]: def _run_chronus(self, chronus_args: List[str]) -> int: """Run a chronus command via ``npx`` from the repository root. + Before execution the method verifies that Chronus is installed + locally and uses ``npx --no`` to prevent automatic downloads + from the npm registry (supply-chain safety). + stdin/stdout/stderr are inherited so that interactive prompts (e.g. ``chronus add``) work transparently. """ + self._ensure_chronus_installed() npx = self._get_npx() - cmd = [npx, "chronus"] + chronus_args + cmd = [npx, "--no", "chronus"] + chronus_args logger.info(f"Running: {' '.join(cmd)}") return subprocess.call(cmd, cwd=REPO_ROOT) diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index d3205f720ce2..f063d7e156bd 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -2,11 +2,12 @@ import argparse import os -from unittest.mock import patch, MagicMock +import sys +from unittest.mock import patch, MagicMock, call import pytest -from azpysdk.changelog import changelog, REPO_ROOT +from azpysdk.changelog import changelog, REPO_ROOT, _CHRONUS_MODULE_PATH # --------------------------------------------------------------------------- @@ -72,11 +73,16 @@ def test_changelog_no_subcommand_prints_help(self): class TestChangelogExecution: - """Verify that each subcommand invokes chronus with the right arguments.""" + """Verify that each subcommand invokes chronus with the right arguments. + All tests in this class patch ``_is_chronus_installed`` to return True + so the installation check is bypassed. + """ + + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_calls_chronus_add(self, mock_which, mock_call): + def test_add_calls_chronus_add(self, mock_which, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "add"]) # Run from repo root so CWD detection does NOT inject a package @@ -85,21 +91,23 @@ def test_add_calls_chronus_add(self, mock_which, mock_call): assert result == 0 mock_call.assert_called_once() cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "add"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_with_package_passes_package(self, mock_which, mock_call): + def test_add_with_package_passes_package(self, mock_which, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "add", "sdk/core/azure-core"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/core/azure-core"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/core/azure-core"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_detects_package_from_cwd(self, mock_which, mock_call): + def test_add_detects_package_from_cwd(self, mock_which, mock_call, _mock_installed): """When CWD is inside a package dir and no package arg is given, detect it.""" parser = _build_parser() args = parser.parse_args(["changelog", "add"]) @@ -108,11 +116,12 @@ def test_add_detects_package_from_cwd(self, mock_which, mock_call): result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/storage/azure-storage-blob"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/storage/azure-storage-blob"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_detects_package_from_subdirectory(self, mock_which, mock_call): + def test_add_detects_package_from_subdirectory(self, mock_which, mock_call, _mock_installed): """When CWD is a subdirectory of a package, detect the package root.""" parser = _build_parser() args = parser.parse_args(["changelog", "add"]) @@ -121,11 +130,12 @@ def test_add_detects_package_from_subdirectory(self, mock_which, mock_call): result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/storage/azure-storage-blob"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/storage/azure-storage-blob"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call): + def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call, _mock_installed): """An explicit package argument takes precedence over CWD detection.""" parser = _build_parser() args = parser.parse_args(["changelog", "add", "sdk/core/azure-core"]) @@ -134,41 +144,45 @@ def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call): result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/core/azure-core"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/core/azure-core"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_verify_calls_chronus_verify(self, mock_which, mock_call): + def test_verify_calls_chronus_verify(self, mock_which, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "verify"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "verify"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "verify"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_create_calls_chronus_changelog(self, mock_which, mock_call): + def test_create_calls_chronus_changelog(self, mock_which, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "create"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "changelog"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "changelog"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_status_calls_chronus_status(self, mock_which, mock_call): + def test_status_calls_chronus_status(self, mock_which, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "status"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "chronus", "status"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "status"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_chronus_runs_from_repo_root(self, mock_which, mock_call): + def test_chronus_runs_from_repo_root(self, mock_which, mock_call, _mock_installed): """Chronus must run from the repository root directory.""" parser = _build_parser() args = parser.parse_args(["changelog", "verify"]) @@ -176,9 +190,10 @@ def test_chronus_runs_from_repo_root(self, mock_which, mock_call): _, kwargs = mock_call.call_args assert kwargs["cwd"] == REPO_ROOT + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=1) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_nonzero_exit_code_propagated(self, mock_which, mock_call): + def test_nonzero_exit_code_propagated(self, mock_which, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "verify"]) result = args.func(args) @@ -232,9 +247,101 @@ def test_outside_repo(self): class TestChangelogErrors: """Verify error handling when prerequisites are missing.""" + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.shutil.which", return_value=None) - def test_npx_not_found_raises(self, mock_which): + def test_npx_not_found_raises(self, mock_which, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "verify"]) with pytest.raises(FileNotFoundError, match="npx not found"): args.func(args) + + +# --------------------------------------------------------------------------- +# Chronus installation check tests +# --------------------------------------------------------------------------- + + +class TestEnsureChronusInstalled: + """Verify the _ensure_chronus_installed safety gate.""" + + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) + def test_already_installed_returns_immediately(self, mock_installed): + """When Chronus is already installed, no action is needed.""" + c = changelog() + c._ensure_chronus_installed() # should not raise + + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=False) + @patch("azpysdk.changelog.shutil.which", return_value=None) + def test_npm_not_found_exits(self, mock_which, mock_installed): + """When npm is not on PATH, exit with an error.""" + c = changelog() + with pytest.raises(SystemExit): + c._ensure_chronus_installed() + + @patch("azpysdk.changelog.changelog._is_chronus_installed", side_effect=[False, True]) + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") + @patch("azpysdk.changelog.sys.stdin") + def test_non_interactive_installs_automatically(self, mock_stdin, mock_which, mock_call, mock_installed): + """In non-interactive mode, npm install runs without prompting.""" + mock_stdin.isatty.return_value = False + c = changelog() + c._ensure_chronus_installed() # should not raise + mock_call.assert_called_once() + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npm", "install"] + + @patch("azpysdk.changelog.changelog._is_chronus_installed", side_effect=[False, True]) + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") + @patch("builtins.input", return_value="y") + @patch("azpysdk.changelog.sys.stdin") + def test_interactive_user_accepts_install(self, mock_stdin, mock_input, mock_which, mock_call, mock_installed): + """When the user says 'y', npm install is executed.""" + mock_stdin.isatty.return_value = True + c = changelog() + c._ensure_chronus_installed() # should not raise + mock_call.assert_called_once() + + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=False) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") + @patch("builtins.input", return_value="n") + @patch("azpysdk.changelog.sys.stdin") + def test_interactive_user_declines_exits(self, mock_stdin, mock_input, mock_which, mock_installed): + """When the user says 'n', exit without installing.""" + mock_stdin.isatty.return_value = True + c = changelog() + with pytest.raises(SystemExit): + c._ensure_chronus_installed() + + @patch("azpysdk.changelog.changelog._is_chronus_installed", side_effect=[False, False]) + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") + @patch("azpysdk.changelog.sys.stdin") + def test_install_succeeds_but_chronus_still_missing(self, mock_stdin, mock_which, mock_call, mock_installed): + """If npm install succeeds but chronus isn't found, exit with error.""" + mock_stdin.isatty.return_value = False + c = changelog() + with pytest.raises(SystemExit): + c._ensure_chronus_installed() + + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=False) + @patch("azpysdk.changelog.subprocess.call", return_value=1) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") + @patch("azpysdk.changelog.sys.stdin") + def test_npm_install_failure_exits(self, mock_stdin, mock_which, mock_call, mock_installed): + """If npm install fails, exit with the npm exit code.""" + mock_stdin.isatty.return_value = False + c = changelog() + with pytest.raises(SystemExit): + c._ensure_chronus_installed() + + def test_is_chronus_installed_checks_node_modules(self): + """_is_chronus_installed should check for the chronus directory in node_modules.""" + c = changelog() + expected_path = os.path.join(REPO_ROOT, _CHRONUS_MODULE_PATH) + with patch("os.path.isdir", return_value=True) as mock_isdir: + assert c._is_chronus_installed() is True + mock_isdir.assert_called_once_with(expected_path) + with patch("os.path.isdir", return_value=False) as mock_isdir: + assert c._is_chronus_installed() is False From 235c5005486f5f47dce07b038506a5fce02630d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:42:43 +0000 Subject: [PATCH 05/11] Add --kind and --message flags to changelog add for breaking changes Add --kind (-k) and --message (-m) flags to `azpysdk changelog add` that forward to chronus's native --kind and --message options. This lets developers tag entries as breaking (or any other change kind) non-interactively: azpysdk changelog add --kind breaking -m "Removed deprecated API" azpysdk changelog add -k feature -m "Added new endpoint" Valid kinds: breaking, feature, deprecation, fix, dependencies, internal Also fix _ensure_chronus_installed to skip the interactive prompt when stdin is not a TTY (non-interactive / CI environments). Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/aa3dee26-9283-4594-a49f-64fc64d3d995 Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> --- .../azure-sdk-tools/azpysdk/changelog.py | 53 ++++++++++-- .../tests/test_changelog_commands.py | 86 ++++++++++++++++++- 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index b06b42bfc76b..dca2a60b9a5d 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -12,6 +12,10 @@ _CHRONUS_PACKAGE = "@chronus/chronus" _CHRONUS_MODULE_PATH = os.path.join("node_modules", "@chronus", "chronus") +# Valid change kinds from .chronus/config.yaml. Kept in sync manually +# so the CLI can validate before calling out to chronus. +_CHANGE_KINDS = ["breaking", "feature", "deprecation", "fix", "dependencies", "internal"] + class changelog(Check): """Manage changelogs with Chronus. @@ -53,6 +57,23 @@ def register( "automatically. Otherwise chronus detects modified packages interactively." ), ) + add_p.add_argument( + "--kind", "-k", + choices=_CHANGE_KINDS, + default=None, + help=( + "Kind of change (e.g. breaking, feature, fix). " + "If omitted, chronus will prompt interactively." + ), + ) + add_p.add_argument( + "--message", "-m", + default=None, + help=( + "Short description of the change. " + "If omitted, chronus will prompt interactively." + ), + ) add_p.set_defaults(func=self._run_add) # changelog verify @@ -122,14 +143,17 @@ def _ensure_chronus_installed(self) -> None: raise SystemExit(1) - print( - "\nChronus is not installed locally. It is listed as a dev dependency\n" - f"in {os.path.join(REPO_ROOT, 'package.json')}.\n" - ) - answer = input("Run 'npm install' in the repo root to install it? [Y/n] ").strip().lower() - if answer not in ("", "y", "yes"): - logger.info("Skipped Chronus installation.") - raise SystemExit(1) + if sys.stdin.isatty(): + print( + "\nChronus is not installed locally. It is listed as a dev dependency\n" + f"in {os.path.join(REPO_ROOT, 'package.json')}.\n" + ) + answer = input("Run 'npm install' in the repo root to install it? [Y/n] ").strip().lower() + if answer not in ("", "y", "yes"): + logger.info("Skipped Chronus installation.") + raise SystemExit(1) + else: + logger.info("Chronus not installed — running 'npm install' automatically (non-interactive).") logger.info(f"Running: npm install (cwd: {REPO_ROOT})") rc = subprocess.call([npm, "install"], cwd=REPO_ROOT) @@ -194,6 +218,10 @@ def _run_add(self, args: argparse.Namespace) -> int: When no *package* argument is given but CWD is inside a package directory (``sdk//``), the package path is detected automatically so the developer doesn't have to specify it. + + Optional ``--kind`` and ``--message`` flags are forwarded to chronus + so the developer can skip the interactive prompts (e.g. + ``azpysdk changelog add --kind breaking -m "Removed foo API"``). """ chronus_args = ["add"] package = getattr(args, "package", None) @@ -203,6 +231,15 @@ def _run_add(self, args: argparse.Namespace) -> int: logger.info(f"Detected package from current directory: {package}") if package: chronus_args.append(package) + + kind = getattr(args, "kind", None) + if kind: + chronus_args.extend(["--kind", kind]) + + message = getattr(args, "message", None) + if message: + chronus_args.extend(["--message", message]) + return self._run_chronus(chronus_args) def _run_verify(self, args: argparse.Namespace) -> int: diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index f063d7e156bd..d97a090f788a 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -7,7 +7,7 @@ import pytest -from azpysdk.changelog import changelog, REPO_ROOT, _CHRONUS_MODULE_PATH +from azpysdk.changelog import changelog, REPO_ROOT, _CHRONUS_MODULE_PATH, _CHANGE_KINDS # --------------------------------------------------------------------------- @@ -47,6 +47,46 @@ def test_changelog_add_with_package(self): assert args.changelog_command == "add" assert args.package == "sdk/storage/azure-storage-blob" + def test_changelog_add_with_kind(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "--kind", "breaking"]) + assert args.kind == "breaking" + + def test_changelog_add_with_kind_short(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "-k", "feature"]) + assert args.kind == "feature" + + def test_changelog_add_with_message(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "--message", "Fixed the bug"]) + assert args.message == "Fixed the bug" + + def test_changelog_add_with_message_short(self): + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "-m", "Added new API"]) + assert args.message == "Added new API" + + def test_changelog_add_with_kind_and_message(self): + parser = _build_parser() + args = parser.parse_args( + ["changelog", "add", "sdk/core/azure-core", "--kind", "breaking", "-m", "Removed old API"] + ) + assert args.package == "sdk/core/azure-core" + assert args.kind == "breaking" + assert args.message == "Removed old API" + + def test_changelog_add_invalid_kind_rejected(self): + parser = _build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["changelog", "add", "--kind", "notakind"]) + + def test_changelog_add_all_valid_kinds_accepted(self): + parser = _build_parser() + for kind in _CHANGE_KINDS: + args = parser.parse_args(["changelog", "add", "--kind", kind]) + assert args.kind == kind + def test_changelog_create(self): parser = _build_parser() args = parser.parse_args(["changelog", "create"]) @@ -146,6 +186,50 @@ def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call, _mock_i cmd = mock_call.call_args[0][0] assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/core/azure-core"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_with_kind_passes_flag(self, mock_which, mock_call, _mock_installed): + """The --kind flag should be forwarded to chronus.""" + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "--kind", "breaking"]) + with patch("os.getcwd", return_value=REPO_ROOT): + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "--kind", "breaking"] + + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_with_message_passes_flag(self, mock_which, mock_call, _mock_installed): + """The --message flag should be forwarded to chronus.""" + parser = _build_parser() + args = parser.parse_args(["changelog", "add", "-m", "Fixed upload bug"]) + with patch("os.getcwd", return_value=REPO_ROOT): + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "--message", "Fixed upload bug"] + + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) + @patch("azpysdk.changelog.subprocess.call", return_value=0) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") + def test_add_with_kind_message_and_package(self, mock_which, mock_call, _mock_installed): + """All flags (package, --kind, --message) should be forwarded together.""" + parser = _build_parser() + args = parser.parse_args([ + "changelog", "add", "sdk/core/azure-core", + "--kind", "breaking", "-m", "Removed deprecated API", + ]) + result = args.func(args) + assert result == 0 + cmd = mock_call.call_args[0][0] + assert cmd == [ + "/usr/bin/npx", "--no", "chronus", "add", "sdk/core/azure-core", + "--kind", "breaking", "--message", "Removed deprecated API", + ] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") From 2aae69b5748b6fc354f7f6ca1a6002f0e872e165 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:07:12 +0000 Subject: [PATCH 06/11] Address review: dynamic change kinds, AZPYSDK_AUTO_INSTALL gate, direct attr access, parser on instance 1. Read _CHANGE_KINDS from .chronus/config.yaml via _load_change_kinds() with hardcoded fallback when config or pyyaml is unavailable. 2. Non-interactive (CI) mode now requires AZPYSDK_AUTO_INSTALL=1 env var before running npm install automatically. 3. Use args.package/args.kind/args.message directly instead of getattr. 4. Store parser reference as self._parser on the changelog class instance instead of stashing a private attr on the argparse Namespace. Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/c9c4bf6e-9283-48b9-9861-00062d368e90 Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> --- .../azure-sdk-tools/azpysdk/changelog.py | 55 +++++++++++---- .../tests/test_changelog_commands.py | 70 ++++++++++++++++--- 2 files changed, 104 insertions(+), 21 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index dca2a60b9a5d..52ebaa8972ee 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -12,9 +12,30 @@ _CHRONUS_PACKAGE = "@chronus/chronus" _CHRONUS_MODULE_PATH = os.path.join("node_modules", "@chronus", "chronus") -# Valid change kinds from .chronus/config.yaml. Kept in sync manually -# so the CLI can validate before calling out to chronus. -_CHANGE_KINDS = ["breaking", "feature", "deprecation", "fix", "dependencies", "internal"] +_FALLBACK_CHANGE_KINDS = ["breaking", "feature", "deprecation", "fix", "dependencies", "internal"] + + +def _load_change_kinds() -> List[str]: + """Read valid change kinds from ``.chronus/config.yaml``. + + Falls back to a hardcoded list if the config file is missing or + cannot be parsed (e.g. pyyaml not installed). + """ + config_path = os.path.join(REPO_ROOT, ".chronus", "config.yaml") + try: + import yaml + + with open(config_path) as f: + config = yaml.safe_load(f) + kinds = list(config.get("changeKinds", {}).keys()) + if kinds: + return kinds + except Exception: + pass + return list(_FALLBACK_CHANGE_KINDS) + + +_CHANGE_KINDS = _load_change_kinds() class changelog(Check): @@ -28,6 +49,7 @@ class changelog(Check): def __init__(self) -> None: super().__init__() + self._parser: Optional[argparse.ArgumentParser] = None def register( self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None @@ -42,6 +64,7 @@ def register( "changelog", help="Manage changelogs with Chronus (add, verify, create, status)", ) + self._parser = p changelog_sub = p.add_subparsers(title="changelog commands", dest="changelog_command") @@ -93,7 +116,7 @@ def register( status_p.set_defaults(func=self._run_status) # Default behaviour when no subcommand is given - p.set_defaults(func=self._no_subcommand, _changelog_parser=p) + p.set_defaults(func=self._no_subcommand) # ------------------------------------------------------------------ # Internal helpers @@ -101,7 +124,7 @@ def register( def _no_subcommand(self, args: argparse.Namespace) -> int: """Print help when no changelog subcommand is provided.""" - args._changelog_parser.print_help() + self._parser.print_help() return 1 def _get_npx(self) -> str: @@ -153,7 +176,15 @@ def _ensure_chronus_installed(self) -> None: logger.info("Skipped Chronus installation.") raise SystemExit(1) else: - logger.info("Chronus not installed — running 'npm install' automatically (non-interactive).") + if not os.environ.get("AZPYSDK_AUTO_INSTALL"): + logger.error( + "Chronus is not installed and running in non-interactive mode.\n" + "Set AZPYSDK_AUTO_INSTALL=1 to allow automatic installation, or run:\n\n" + f" cd {REPO_ROOT}\n" + " npm install\n" + ) + raise SystemExit(1) + logger.info("AZPYSDK_AUTO_INSTALL set — running 'npm install' automatically.") logger.info(f"Running: npm install (cwd: {REPO_ROOT})") rc = subprocess.call([npm, "install"], cwd=REPO_ROOT) @@ -224,7 +255,7 @@ def _run_add(self, args: argparse.Namespace) -> int: ``azpysdk changelog add --kind breaking -m "Removed foo API"``). """ chronus_args = ["add"] - package = getattr(args, "package", None) + package = args.package if not package: package = self._detect_package_from_cwd() if package: @@ -232,13 +263,11 @@ def _run_add(self, args: argparse.Namespace) -> int: if package: chronus_args.append(package) - kind = getattr(args, "kind", None) - if kind: - chronus_args.extend(["--kind", kind]) + if args.kind: + chronus_args.extend(["--kind", args.kind]) - message = getattr(args, "message", None) - if message: - chronus_args.extend(["--message", message]) + if args.message: + chronus_args.extend(["--message", args.message]) return self._run_chronus(chronus_args) diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index d97a090f788a..e0d58b8e74a9 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -7,7 +7,14 @@ import pytest -from azpysdk.changelog import changelog, REPO_ROOT, _CHRONUS_MODULE_PATH, _CHANGE_KINDS +from azpysdk.changelog import ( + changelog, + REPO_ROOT, + _CHRONUS_MODULE_PATH, + _CHANGE_KINDS, + _FALLBACK_CHANGE_KINDS, + _load_change_kinds, +) # --------------------------------------------------------------------------- @@ -107,6 +114,37 @@ def test_changelog_no_subcommand_prints_help(self): assert result == 1 +# --------------------------------------------------------------------------- +# _load_change_kinds tests +# --------------------------------------------------------------------------- + + +class TestLoadChangeKinds: + """Verify that change kinds are loaded dynamically from config.""" + + def test_loaded_kinds_match_config_file(self): + """_CHANGE_KINDS should match the keys in .chronus/config.yaml.""" + import yaml + + config_path = os.path.join(REPO_ROOT, ".chronus", "config.yaml") + with open(config_path) as f: + config = yaml.safe_load(f) + expected = list(config["changeKinds"].keys()) + assert _CHANGE_KINDS == expected + + def test_fallback_when_config_missing(self): + """When the config file doesn't exist, fall back to hardcoded list.""" + with patch("builtins.open", side_effect=FileNotFoundError): + kinds = _load_change_kinds() + assert kinds == _FALLBACK_CHANGE_KINDS + + def test_fallback_when_yaml_parse_fails(self): + """When YAML parsing fails, fall back to hardcoded list.""" + with patch("builtins.open", side_effect=Exception("bad yaml")): + kinds = _load_change_kinds() + assert kinds == _FALLBACK_CHANGE_KINDS + + # --------------------------------------------------------------------------- # Execution tests (npx / chronus invocation) # --------------------------------------------------------------------------- @@ -366,15 +404,29 @@ def test_npm_not_found_exits(self, mock_which, mock_installed): @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") @patch("azpysdk.changelog.sys.stdin") - def test_non_interactive_installs_automatically(self, mock_stdin, mock_which, mock_call, mock_installed): - """In non-interactive mode, npm install runs without prompting.""" + def test_non_interactive_with_auto_install_env(self, mock_stdin, mock_which, mock_call, mock_installed): + """In non-interactive mode with AZPYSDK_AUTO_INSTALL=1, npm install runs.""" mock_stdin.isatty.return_value = False c = changelog() - c._ensure_chronus_installed() # should not raise + with patch.dict(os.environ, {"AZPYSDK_AUTO_INSTALL": "1"}): + c._ensure_chronus_installed() # should not raise mock_call.assert_called_once() cmd = mock_call.call_args[0][0] assert cmd == ["/usr/bin/npm", "install"] + @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=False) + @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") + @patch("azpysdk.changelog.sys.stdin") + def test_non_interactive_without_env_exits(self, mock_stdin, mock_which, mock_installed): + """In non-interactive mode without AZPYSDK_AUTO_INSTALL, exit with error.""" + mock_stdin.isatty.return_value = False + c = changelog() + with patch.dict(os.environ, {}, clear=False): + # Ensure AZPYSDK_AUTO_INSTALL is not set + os.environ.pop("AZPYSDK_AUTO_INSTALL", None) + with pytest.raises(SystemExit): + c._ensure_chronus_installed() + @patch("azpysdk.changelog.changelog._is_chronus_installed", side_effect=[False, True]) @patch("azpysdk.changelog.subprocess.call", return_value=0) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") @@ -406,8 +458,9 @@ def test_install_succeeds_but_chronus_still_missing(self, mock_stdin, mock_which """If npm install succeeds but chronus isn't found, exit with error.""" mock_stdin.isatty.return_value = False c = changelog() - with pytest.raises(SystemExit): - c._ensure_chronus_installed() + with patch.dict(os.environ, {"AZPYSDK_AUTO_INSTALL": "1"}): + with pytest.raises(SystemExit): + c._ensure_chronus_installed() @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=False) @patch("azpysdk.changelog.subprocess.call", return_value=1) @@ -417,8 +470,9 @@ def test_npm_install_failure_exits(self, mock_stdin, mock_which, mock_call, mock """If npm install fails, exit with the npm exit code.""" mock_stdin.isatty.return_value = False c = changelog() - with pytest.raises(SystemExit): - c._ensure_chronus_installed() + with patch.dict(os.environ, {"AZPYSDK_AUTO_INSTALL": "1"}): + with pytest.raises(SystemExit): + c._ensure_chronus_installed() def test_is_chronus_installed_checks_node_modules(self): """_is_chronus_installed should check for the chronus directory in node_modules.""" From 648a0615b128ac0d552cd1261f1bb09c8c85ffdc Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 23 Apr 2026 08:39:19 -0700 Subject: [PATCH 07/11] fix Co-authored-by: Copilot --- .../azure-sdk-tools/azpysdk/changelog.py | 112 ++++++++++++++++-- 1 file changed, 103 insertions(+), 9 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index 52ebaa8972ee..170dc13ea983 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -107,12 +107,32 @@ def register( create_p = changelog_sub.add_parser( "create", help="Generate CHANGELOG.md from pending chronus entries" ) + create_p.add_argument( + "package", + nargs="?", + default=None, + help=( + "Package path (e.g. sdk/storage/azure-storage-blob) to generate changelog for. " + "If omitted and CWD is inside a package directory, the package is detected " + "automatically. Otherwise chronus generates changelogs for all packages." + ), + ) create_p.set_defaults(func=self._run_create) # changelog status status_p = changelog_sub.add_parser( "status", help="Show a summary of pending changes and resulting version bumps" ) + status_p.add_argument( + "package", + nargs="?", + default=None, + help=( + "Package path (e.g. sdk/storage/azure-storage-blob) to show status for. " + "If omitted and CWD is inside a package directory, the package is detected " + "automatically. Otherwise chronus shows status for all packages." + ), + ) status_p.set_defaults(func=self._run_status) # Default behaviour when no subcommand is given @@ -202,8 +222,8 @@ def _ensure_chronus_installed(self) -> None: def _detect_package_from_cwd(self) -> Optional[str]: """If CWD is inside a package directory (``sdk//``), - return the relative path from the repository root. Otherwise return - ``None``. + return the Chronus package **name** (the directory basename, e.g. + ``azure-core``). Otherwise return ``None``. The chronus config uses the pattern ``sdk/*/*`` for packages, so we look for CWD being at or below ``/sdk//``. @@ -219,10 +239,38 @@ def _detect_package_from_cwd(self) -> Optional[str]: # rel should start with "sdk//" (at least 3 components) parts = rel.replace("\\", "/").split("/") if len(parts) >= 3 and parts[0] == "sdk": - # Return the first 3 components: sdk// - return "/".join(parts[:3]) + # Return the package name (third component, e.g. "azure-core") + return parts[2] return None + def _resolve_package_name(self, package: str) -> str: + """Resolve a user-supplied package argument to a Chronus package name. + + Accepts either: + - A package name directly (e.g. ``azure-core``) — returned as-is. + - A relative or absolute path (e.g. ``sdk/core/azure-core``, + ``.\\sdk\\core\\azure-core\\``) — resolved to the package name. + + Chronus identifies packages by name (the directory basename under + ``sdk//``), not by path. + """ + # If the path is absolute or starts with '.' resolve it relative to repo root + if os.path.isabs(package) or package.startswith("."): + try: + abs_path = os.path.abspath(os.path.join(os.getcwd(), package)) + repo = os.path.abspath(REPO_ROOT) + package = os.path.relpath(abs_path, repo) + except ValueError: + pass + # Normalize separators and strip trailing slashes + package = package.replace("\\", "/").strip("/") + # If it looks like a path (sdk//...), extract the name + parts = package.split("/") + if len(parts) >= 3 and parts[0] == "sdk": + return parts[2] + # Otherwise assume it's already a package name + return package + def _run_chronus(self, chronus_args: List[str]) -> int: """Run a chronus command via ``npx`` from the repository root. @@ -256,7 +304,9 @@ def _run_add(self, args: argparse.Namespace) -> int: """ chronus_args = ["add"] package = args.package - if not package: + if package: + package = self._resolve_package_name(package) + else: package = self._detect_package_from_cwd() if package: logger.info(f"Detected package from current directory: {package}") @@ -276,9 +326,53 @@ def _run_verify(self, args: argparse.Namespace) -> int: return self._run_chronus(["verify"]) def _run_create(self, args: argparse.Namespace) -> int: - """Run ``chronus changelog`` to generate CHANGELOG.md files.""" - return self._run_chronus(["changelog"]) + """Run ``chronus changelog`` to generate CHANGELOG.md files. + + When no *package* argument is given but CWD is inside a package + directory, the package path is detected automatically and passed + via ``--package`` so only that package's changelog is generated. + """ + chronus_args = ["changelog"] + package = args.package + if package: + package = self._resolve_package_name(package) + else: + package = self._detect_package_from_cwd() + if package: + logger.info(f"Detected package from current directory: {package}") + if not package: + logger.error( + "No package specified and could not detect one from the current directory.\n" + "Either run from within a package directory (e.g. sdk/core/azure-core) or\n" + "pass the package path explicitly:\n\n" + " azpysdk changelog create sdk/core/azure-core\n" + ) + return 1 + chronus_args.extend(["--package", package]) + rc = self._run_chronus(chronus_args) + if rc != 0: + logger.info( + "Hint: if Chronus reported 'No release action found', it means there are no\n" + "pending change entries for this package. Run 'azpysdk changelog add' first to\n" + "create a change entry, then re-run 'azpysdk changelog create'." + ) + return rc def _run_status(self, args: argparse.Namespace) -> int: - """Run ``chronus status`` to show pending changes.""" - return self._run_chronus(["status"]) + """Run ``chronus status`` to show pending changes. + + When no *package* argument is given but CWD is inside a package + directory, the package path is detected automatically and passed + via ``--only`` so only that package's status is shown. + """ + chronus_args = ["status"] + package = args.package + if package: + package = self._resolve_package_name(package) + else: + package = self._detect_package_from_cwd() + if package: + logger.info(f"Detected package from current directory: {package}") + if package: + chronus_args.extend(["--only", package]) + return self._run_chronus(chronus_args) From 4de14e0a91a705f32ea860fd735a001c09763fa1 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 23 Apr 2026 08:58:45 -0700 Subject: [PATCH 08/11] changes --- .github/workflows/azure-sdk-tools.yml | 1 + .../azure-sdk-tools/azpysdk/changelog.py | 23 ++++------ .../tests/test_changelog_commands.py | 43 +++++++++++++------ 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/.github/workflows/azure-sdk-tools.yml b/.github/workflows/azure-sdk-tools.yml index ed1a6ed97da9..434aae4010f9 100644 --- a/.github/workflows/azure-sdk-tools.yml +++ b/.github/workflows/azure-sdk-tools.yml @@ -79,6 +79,7 @@ jobs: tr -d '{}' | \ tr ',' '\n' | \ grep -v '^next-' | \ + grep -v '^changelog$' | \ sort | \ paste -sd,) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index 170dc13ea983..8bd0bf52855e 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -81,21 +81,17 @@ def register( ), ) add_p.add_argument( - "--kind", "-k", + "--kind", + "-k", choices=_CHANGE_KINDS, default=None, - help=( - "Kind of change (e.g. breaking, feature, fix). " - "If omitted, chronus will prompt interactively." - ), + help=("Kind of change (e.g. breaking, feature, fix). " "If omitted, chronus will prompt interactively."), ) add_p.add_argument( - "--message", "-m", + "--message", + "-m", default=None, - help=( - "Short description of the change. " - "If omitted, chronus will prompt interactively." - ), + help=("Short description of the change. " "If omitted, chronus will prompt interactively."), ) add_p.set_defaults(func=self._run_add) @@ -104,9 +100,7 @@ def register( verify_p.set_defaults(func=self._run_verify) # changelog create - create_p = changelog_sub.add_parser( - "create", help="Generate CHANGELOG.md from pending chronus entries" - ) + create_p = changelog_sub.add_parser("create", help="Generate CHANGELOG.md from pending chronus entries") create_p.add_argument( "package", nargs="?", @@ -185,7 +179,6 @@ def _ensure_chronus_installed(self) -> None: ) raise SystemExit(1) - if sys.stdin.isatty(): print( "\nChronus is not installed locally. It is listed as a dev dependency\n" @@ -205,7 +198,7 @@ def _ensure_chronus_installed(self) -> None: ) raise SystemExit(1) logger.info("AZPYSDK_AUTO_INSTALL set — running 'npm install' automatically.") - + logger.info(f"Running: npm install (cwd: {REPO_ROOT})") rc = subprocess.call([npm, "install"], cwd=REPO_ROOT) if rc != 0: diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index e0d58b8e74a9..c72255bca24e 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -21,6 +21,7 @@ # Helper – build a minimal parser that includes the changelog subcommands # --------------------------------------------------------------------------- + def _build_parser(): parser = argparse.ArgumentParser(prog="azpysdk") subparsers = parser.add_subparsers(title="commands", dest="command") @@ -180,7 +181,7 @@ def test_add_with_package_passes_package(self, mock_which, mock_call, _mock_inst result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/core/azure-core"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-core"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @@ -194,7 +195,7 @@ def test_add_detects_package_from_cwd(self, mock_which, mock_call, _mock_install result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/storage/azure-storage-blob"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-storage-blob"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @@ -208,7 +209,7 @@ def test_add_detects_package_from_subdirectory(self, mock_which, mock_call, _moc result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/storage/azure-storage-blob"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-storage-blob"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @@ -222,7 +223,7 @@ def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call, _mock_i result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "sdk/core/azure-core"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-core"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @@ -256,16 +257,30 @@ def test_add_with_message_passes_flag(self, mock_which, mock_call, _mock_install def test_add_with_kind_message_and_package(self, mock_which, mock_call, _mock_installed): """All flags (package, --kind, --message) should be forwarded together.""" parser = _build_parser() - args = parser.parse_args([ - "changelog", "add", "sdk/core/azure-core", - "--kind", "breaking", "-m", "Removed deprecated API", - ]) + args = parser.parse_args( + [ + "changelog", + "add", + "sdk/core/azure-core", + "--kind", + "breaking", + "-m", + "Removed deprecated API", + ] + ) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] assert cmd == [ - "/usr/bin/npx", "--no", "chronus", "add", "sdk/core/azure-core", - "--kind", "breaking", "--message", "Removed deprecated API", + "/usr/bin/npx", + "--no", + "chronus", + "add", + "azure-core", + "--kind", + "breaking", + "--message", + "Removed deprecated API", ] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @@ -284,11 +299,11 @@ def test_verify_calls_chronus_verify(self, mock_which, mock_call, _mock_installe @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") def test_create_calls_chronus_changelog(self, mock_which, mock_call, _mock_installed): parser = _build_parser() - args = parser.parse_args(["changelog", "create"]) + args = parser.parse_args(["changelog", "create", "sdk/core/azure-core"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "changelog"] + assert cmd == ["/usr/bin/npx", "--no", "chronus", "changelog", "--package", "azure-core"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) @@ -333,12 +348,12 @@ class TestDetectPackageFromCwd: def test_at_package_root(self): c = changelog() with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "core", "azure-core")): - assert c._detect_package_from_cwd() == "sdk/core/azure-core" + assert c._detect_package_from_cwd() == "azure-core" def test_inside_package_subdir(self): c = changelog() with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "core", "azure-core", "azure", "core")): - assert c._detect_package_from_cwd() == "sdk/core/azure-core" + assert c._detect_package_from_cwd() == "azure-core" def test_at_repo_root(self): c = changelog() From 49a2fd013440ef97cc06687cc3d516750875509d Mon Sep 17 00:00:00 2001 From: l0lawrence Date: Thu, 23 Apr 2026 11:08:57 -0700 Subject: [PATCH 09/11] Pin Chronus: move to .github/chronus with exact version 1.3.1 + lockfile The prior root package.json/lockfile were gitignored (the root is reserved for tsp-client transient files), so they never actually shipped. Move the pinned Chronus dev dependency to .github/chronus/ and commit both package.json (exact version 1.3.1, no caret) and package-lock.json so transitive deps are locked with integrity hashes. Update azpysdk/changelog.py to: - look for chronus under .github/chronus/node_modules - install via 'npm ci' (honors lockfile, fails on drift) instead of 'npm install' - invoke the pinned chronus binary directly instead of via npx, so there is no ambient resolution or registry lookup even if the local install is somehow incomplete Tests updated accordingly; all 44 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/chronus/package-lock.json | 1998 +++++++++++++++++ .github/chronus/package.json | 8 + .../azure-sdk-tools/azpysdk/changelog.py | 75 +- .../tests/test_changelog_commands.py | 102 +- 4 files changed, 2088 insertions(+), 95 deletions(-) create mode 100644 .github/chronus/package-lock.json create mode 100644 .github/chronus/package.json diff --git a/.github/chronus/package-lock.json b/.github/chronus/package-lock.json new file mode 100644 index 000000000000..889f353e46e4 --- /dev/null +++ b/.github/chronus/package-lock.json @@ -0,0 +1,1998 @@ +{ + "name": "azure-sdk-for-python-changelog-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "azure-sdk-for-python-changelog-tools", + "devDependencies": { + "@chronus/chronus": "1.3.1" + } + }, + "node_modules/@chronus/chronus": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@chronus/chronus/-/chronus-1.3.1.tgz", + "integrity": "sha512-qSrHpXL/LlOlvW0TPCxIkZnvTdXEFW0cHoyS9lsq6CIIondtgcm4y/VEMK4wnGHyuHLvmuYASAjVSVbgMvmHTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "globby": "^16.1.0", + "is-unicode-supported": "^2.1.0", + "micromatch": "^4.0.8", + "pacote": "^21.1.0", + "picocolors": "^1.1.1", + "pluralize": "^8.0.0", + "prompts": "^2.4.2", + "semver": "^7.7.2", + "smol-toml": "^1.4.1", + "source-map-support": "^0.5.21", + "std-env": "^3.9.0", + "yaml": "^2.8.1", + "yargs": "^18.0.0", + "zod": "^4.3.6" + }, + "bin": { + "chronus": "cmd/cli.mjs", + "kro": "cmd/cli.mjs" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.0.tgz", + "integrity": "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", + "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", + "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pacote": { + "version": "21.5.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.0.tgz", + "integrity": "sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sigstore": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", + "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.github/chronus/package.json b/.github/chronus/package.json new file mode 100644 index 000000000000..474749bd3b74 --- /dev/null +++ b/.github/chronus/package.json @@ -0,0 +1,8 @@ +{ + "name": "azure-sdk-for-python-changelog-tools", + "private": true, + "description": "Pinned Node dev dependencies used by 'azpysdk changelog' (Chronus).", + "devDependencies": { + "@chronus/chronus": "1.3.1" + } +} diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index 8bd0bf52855e..fd45d715fb37 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -8,9 +8,15 @@ from .Check import Check, REPO_ROOT from ci_tools.logging import logger -# The expected Chronus package name in node_modules +# The expected Chronus package name and on-disk install location. +# Chronus is pinned as a dev dependency in .github/chronus/package.json with +# a committed lockfile so both the top-level version and all transitive +# dependencies are reproducible. _CHRONUS_PACKAGE = "@chronus/chronus" -_CHRONUS_MODULE_PATH = os.path.join("node_modules", "@chronus", "chronus") +_CHRONUS_INSTALL_DIR = os.path.join(".github", "chronus") +_CHRONUS_MODULE_PATH = os.path.join(_CHRONUS_INSTALL_DIR, "node_modules", "@chronus", "chronus") +_CHRONUS_BIN_NAME = "chronus.cmd" if os.name == "nt" else "chronus" +_CHRONUS_BIN_PATH = os.path.join(_CHRONUS_INSTALL_DIR, "node_modules", ".bin", _CHRONUS_BIN_NAME) _FALLBACK_CHANGE_KINDS = ["breaking", "feature", "deprecation", "fix", "dependencies", "internal"] @@ -141,50 +147,41 @@ def _no_subcommand(self, args: argparse.Namespace) -> int: self._parser.print_help() return 1 - def _get_npx(self) -> str: - """Locate the ``npx`` executable on *PATH*.""" - npx = shutil.which("npx") - if not npx: - logger.error( - "npx is not installed. Chronus requires Node.js. " - "Please install Node.js (LTS) from https://nodejs.org/ and try again." - ) - raise FileNotFoundError("npx not found on PATH") - return npx - def _is_chronus_installed(self) -> bool: """Return ``True`` if Chronus is installed locally in *node_modules*.""" - return os.path.isdir(os.path.join(REPO_ROOT, _CHRONUS_MODULE_PATH)) + return os.path.isfile(os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH)) def _ensure_chronus_installed(self) -> None: """Verify that Chronus is installed locally, offering to install if not. Security: we **never** allow ``npx`` to silently download packages from the npm registry. Instead we check for a local installation - and, when missing, run ``npm install`` against the repo-root - ``package.json`` so only declared dependencies are resolved. + and, when missing, run ``npm ci`` against ``.github/chronus`` so + only the exact versions recorded in ``package-lock.json`` are + installed (with integrity-hash verification). Raises ``SystemExit`` if the user declines or installation fails. """ if self._is_chronus_installed(): return + install_dir = os.path.join(REPO_ROOT, _CHRONUS_INSTALL_DIR) npm = shutil.which("npm") if not npm: logger.error( "Chronus is not installed and npm was not found on PATH.\n" "Please install Node.js (LTS) from https://nodejs.org/ then run:\n\n" - f" cd {REPO_ROOT}\n" - " npm install\n" + f" cd {install_dir}\n" + " npm ci\n" ) raise SystemExit(1) if sys.stdin.isatty(): print( - "\nChronus is not installed locally. It is listed as a dev dependency\n" - f"in {os.path.join(REPO_ROOT, 'package.json')}.\n" + "\nChronus is not installed locally. It is pinned as a dev dependency\n" + f"in {os.path.join(install_dir, 'package.json')}.\n" ) - answer = input("Run 'npm install' in the repo root to install it? [Y/n] ").strip().lower() + answer = input(f"Run 'npm ci' in {_CHRONUS_INSTALL_DIR} to install it? [Y/n] ").strip().lower() if answer not in ("", "y", "yes"): logger.info("Skipped Chronus installation.") raise SystemExit(1) @@ -193,23 +190,24 @@ def _ensure_chronus_installed(self) -> None: logger.error( "Chronus is not installed and running in non-interactive mode.\n" "Set AZPYSDK_AUTO_INSTALL=1 to allow automatic installation, or run:\n\n" - f" cd {REPO_ROOT}\n" - " npm install\n" + f" cd {install_dir}\n" + " npm ci\n" ) raise SystemExit(1) - logger.info("AZPYSDK_AUTO_INSTALL set — running 'npm install' automatically.") + logger.info("AZPYSDK_AUTO_INSTALL set — running 'npm ci' automatically.") - logger.info(f"Running: npm install (cwd: {REPO_ROOT})") - rc = subprocess.call([npm, "install"], cwd=REPO_ROOT) + logger.info(f"Running: npm ci (cwd: {install_dir})") + rc = subprocess.call([npm, "ci"], cwd=install_dir) if rc != 0: - logger.error("'npm install' failed. Please resolve npm errors and try again.") + logger.error("'npm ci' failed. Please resolve npm errors and try again.") raise SystemExit(rc) if not self._is_chronus_installed(): logger.error( - "'npm install' succeeded but Chronus was not found in node_modules.\n" - f"Expected: {os.path.join(REPO_ROOT, _CHRONUS_MODULE_PATH)}\n" - "Please verify that package.json lists @chronus/chronus as a dependency." + "'npm ci' succeeded but Chronus was not found in node_modules.\n" + f"Expected: {os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH)}\n" + f"Please verify that {os.path.join(_CHRONUS_INSTALL_DIR, 'package.json')} " + "lists @chronus/chronus as a dependency." ) raise SystemExit(1) @@ -265,18 +263,17 @@ def _resolve_package_name(self, package: str) -> str: return package def _run_chronus(self, chronus_args: List[str]) -> int: - """Run a chronus command via ``npx`` from the repository root. - - Before execution the method verifies that Chronus is installed - locally and uses ``npx --no`` to prevent automatic downloads - from the npm registry (supply-chain safety). + """Run a chronus command from the repository root. - stdin/stdout/stderr are inherited so that interactive prompts - (e.g. ``chronus add``) work transparently. + Before execution the method verifies that Chronus is installed in + ``.github/chronus/node_modules`` and invokes the pinned binary + directly (rather than via ``npx``) to avoid any registry lookup or + ambient resolution. stdin/stdout/stderr are inherited so that + interactive prompts (e.g. ``chronus add``) work transparently. """ self._ensure_chronus_installed() - npx = self._get_npx() - cmd = [npx, "--no", "chronus"] + chronus_args + chronus_bin = os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH) + cmd = [chronus_bin] + chronus_args logger.info(f"Running: {' '.join(cmd)}") return subprocess.call(cmd, cwd=REPO_ROOT) diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index c72255bca24e..9447c8bbe4c0 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -11,11 +11,16 @@ changelog, REPO_ROOT, _CHRONUS_MODULE_PATH, + _CHRONUS_BIN_PATH, _CHANGE_KINDS, _FALLBACK_CHANGE_KINDS, _load_change_kinds, ) +# Absolute path of the pinned chronus binary — used as the first element of +# the invocation in all the execution tests below. +_CHRONUS_BIN = os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH) + # --------------------------------------------------------------------------- # Helper – build a minimal parser that includes the changelog subcommands @@ -147,7 +152,7 @@ def test_fallback_when_yaml_parse_fails(self): # --------------------------------------------------------------------------- -# Execution tests (npx / chronus invocation) +# Execution tests (chronus invocation) # --------------------------------------------------------------------------- @@ -155,13 +160,13 @@ class TestChangelogExecution: """Verify that each subcommand invokes chronus with the right arguments. All tests in this class patch ``_is_chronus_installed`` to return True - so the installation check is bypassed. + so the installation check is bypassed. Chronus is invoked via the + pinned binary at ``.github/chronus/node_modules/.bin/chronus``. """ @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_calls_chronus_add(self, mock_which, mock_call, _mock_installed): + def test_add_calls_chronus_add(self, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "add"]) # Run from repo root so CWD detection does NOT inject a package @@ -170,23 +175,21 @@ def test_add_calls_chronus_add(self, mock_which, mock_call, _mock_installed): assert result == 0 mock_call.assert_called_once() cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add"] + assert cmd == [_CHRONUS_BIN, "add"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_with_package_passes_package(self, mock_which, mock_call, _mock_installed): + def test_add_with_package_passes_package(self, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "add", "sdk/core/azure-core"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-core"] + assert cmd == [_CHRONUS_BIN, "add", "azure-core"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_detects_package_from_cwd(self, mock_which, mock_call, _mock_installed): + def test_add_detects_package_from_cwd(self, mock_call, _mock_installed): """When CWD is inside a package dir and no package arg is given, detect it.""" parser = _build_parser() args = parser.parse_args(["changelog", "add"]) @@ -195,12 +198,11 @@ def test_add_detects_package_from_cwd(self, mock_which, mock_call, _mock_install result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-storage-blob"] + assert cmd == [_CHRONUS_BIN, "add", "azure-storage-blob"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_detects_package_from_subdirectory(self, mock_which, mock_call, _mock_installed): + def test_add_detects_package_from_subdirectory(self, mock_call, _mock_installed): """When CWD is a subdirectory of a package, detect the package root.""" parser = _build_parser() args = parser.parse_args(["changelog", "add"]) @@ -209,12 +211,11 @@ def test_add_detects_package_from_subdirectory(self, mock_which, mock_call, _moc result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-storage-blob"] + assert cmd == [_CHRONUS_BIN, "add", "azure-storage-blob"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call, _mock_installed): + def test_add_explicit_package_overrides_cwd(self, mock_call, _mock_installed): """An explicit package argument takes precedence over CWD detection.""" parser = _build_parser() args = parser.parse_args(["changelog", "add", "sdk/core/azure-core"]) @@ -223,12 +224,11 @@ def test_add_explicit_package_overrides_cwd(self, mock_which, mock_call, _mock_i result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "azure-core"] + assert cmd == [_CHRONUS_BIN, "add", "azure-core"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_with_kind_passes_flag(self, mock_which, mock_call, _mock_installed): + def test_add_with_kind_passes_flag(self, mock_call, _mock_installed): """The --kind flag should be forwarded to chronus.""" parser = _build_parser() args = parser.parse_args(["changelog", "add", "--kind", "breaking"]) @@ -236,12 +236,11 @@ def test_add_with_kind_passes_flag(self, mock_which, mock_call, _mock_installed) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "--kind", "breaking"] + assert cmd == [_CHRONUS_BIN, "add", "--kind", "breaking"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_with_message_passes_flag(self, mock_which, mock_call, _mock_installed): + def test_add_with_message_passes_flag(self, mock_call, _mock_installed): """The --message flag should be forwarded to chronus.""" parser = _build_parser() args = parser.parse_args(["changelog", "add", "-m", "Fixed upload bug"]) @@ -249,12 +248,11 @@ def test_add_with_message_passes_flag(self, mock_which, mock_call, _mock_install result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "add", "--message", "Fixed upload bug"] + assert cmd == [_CHRONUS_BIN, "add", "--message", "Fixed upload bug"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_add_with_kind_message_and_package(self, mock_which, mock_call, _mock_installed): + def test_add_with_kind_message_and_package(self, mock_call, _mock_installed): """All flags (package, --kind, --message) should be forwarded together.""" parser = _build_parser() args = parser.parse_args( @@ -272,9 +270,7 @@ def test_add_with_kind_message_and_package(self, mock_which, mock_call, _mock_in assert result == 0 cmd = mock_call.call_args[0][0] assert cmd == [ - "/usr/bin/npx", - "--no", - "chronus", + _CHRONUS_BIN, "add", "azure-core", "--kind", @@ -285,41 +281,37 @@ def test_add_with_kind_message_and_package(self, mock_which, mock_call, _mock_in @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_verify_calls_chronus_verify(self, mock_which, mock_call, _mock_installed): + def test_verify_calls_chronus_verify(self, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "verify"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "verify"] + assert cmd == [_CHRONUS_BIN, "verify"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_create_calls_chronus_changelog(self, mock_which, mock_call, _mock_installed): + def test_create_calls_chronus_changelog(self, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "create", "sdk/core/azure-core"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "changelog", "--package", "azure-core"] + assert cmd == [_CHRONUS_BIN, "changelog", "--package", "azure-core"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_status_calls_chronus_status(self, mock_which, mock_call, _mock_installed): + def test_status_calls_chronus_status(self, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "status"]) result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npx", "--no", "chronus", "status"] + assert cmd == [_CHRONUS_BIN, "status"] @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=0) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_chronus_runs_from_repo_root(self, mock_which, mock_call, _mock_installed): + def test_chronus_runs_from_repo_root(self, mock_call, _mock_installed): """Chronus must run from the repository root directory.""" parser = _build_parser() args = parser.parse_args(["changelog", "verify"]) @@ -329,8 +321,7 @@ def test_chronus_runs_from_repo_root(self, mock_which, mock_call, _mock_installe @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) @patch("azpysdk.changelog.subprocess.call", return_value=1) - @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx") - def test_nonzero_exit_code_propagated(self, mock_which, mock_call, _mock_installed): + def test_nonzero_exit_code_propagated(self, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "verify"]) result = args.func(args) @@ -384,13 +375,9 @@ def test_outside_repo(self): class TestChangelogErrors: """Verify error handling when prerequisites are missing.""" - @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=True) - @patch("azpysdk.changelog.shutil.which", return_value=None) - def test_npx_not_found_raises(self, mock_which, _mock_installed): - parser = _build_parser() - args = parser.parse_args(["changelog", "verify"]) - with pytest.raises(FileNotFoundError, match="npx not found"): - args.func(args) + # Note: there is no longer an "npx not found" error path — chronus is + # invoked via the pinned binary under .github/chronus/node_modules/.bin. + # Missing-binary scenarios are exercised by TestEnsureChronusInstalled. # --------------------------------------------------------------------------- @@ -420,14 +407,17 @@ def test_npm_not_found_exits(self, mock_which, mock_installed): @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") @patch("azpysdk.changelog.sys.stdin") def test_non_interactive_with_auto_install_env(self, mock_stdin, mock_which, mock_call, mock_installed): - """In non-interactive mode with AZPYSDK_AUTO_INSTALL=1, npm install runs.""" + """In non-interactive mode with AZPYSDK_AUTO_INSTALL=1, npm ci runs.""" mock_stdin.isatty.return_value = False c = changelog() with patch.dict(os.environ, {"AZPYSDK_AUTO_INSTALL": "1"}): c._ensure_chronus_installed() # should not raise mock_call.assert_called_once() cmd = mock_call.call_args[0][0] - assert cmd == ["/usr/bin/npm", "install"] + assert cmd == ["/usr/bin/npm", "ci"] + # And it must run from the .github/chronus directory, not repo root. + _, kwargs = mock_call.call_args + assert kwargs["cwd"].endswith(os.path.join(".github", "chronus")) @patch("azpysdk.changelog.changelog._is_chronus_installed", return_value=False) @patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npm") @@ -489,12 +479,12 @@ def test_npm_install_failure_exits(self, mock_stdin, mock_which, mock_call, mock with pytest.raises(SystemExit): c._ensure_chronus_installed() - def test_is_chronus_installed_checks_node_modules(self): - """_is_chronus_installed should check for the chronus directory in node_modules.""" + def test_is_chronus_installed_checks_bin(self): + """_is_chronus_installed should check for the chronus binary file.""" c = changelog() - expected_path = os.path.join(REPO_ROOT, _CHRONUS_MODULE_PATH) - with patch("os.path.isdir", return_value=True) as mock_isdir: + expected_path = os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH) + with patch("os.path.isfile", return_value=True) as mock_isfile: assert c._is_chronus_installed() is True - mock_isdir.assert_called_once_with(expected_path) - with patch("os.path.isdir", return_value=False) as mock_isdir: + mock_isfile.assert_called_once_with(expected_path) + with patch("os.path.isfile", return_value=False) as mock_isfile: assert c._is_chronus_installed() is False From 303c66df77171b22a43dd7e2f1e2bbc690e3e037 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 23 Apr 2026 14:18:13 -0700 Subject: [PATCH 10/11] cleaning up --- eng/tools/azure-sdk-tools/azpysdk/changelog.py | 4 ++-- .../tests/test_changelog_commands.py | 14 -------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index fd45d715fb37..8e562e0403e3 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -14,7 +14,6 @@ # dependencies are reproducible. _CHRONUS_PACKAGE = "@chronus/chronus" _CHRONUS_INSTALL_DIR = os.path.join(".github", "chronus") -_CHRONUS_MODULE_PATH = os.path.join(_CHRONUS_INSTALL_DIR, "node_modules", "@chronus", "chronus") _CHRONUS_BIN_NAME = "chronus.cmd" if os.name == "nt" else "chronus" _CHRONUS_BIN_PATH = os.path.join(_CHRONUS_INSTALL_DIR, "node_modules", ".bin", _CHRONUS_BIN_NAME) @@ -144,7 +143,8 @@ def register( def _no_subcommand(self, args: argparse.Namespace) -> int: """Print help when no changelog subcommand is provided.""" - self._parser.print_help() + if self._parser is not None: + self._parser.print_help() return 1 def _is_chronus_installed(self) -> bool: diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index 9447c8bbe4c0..995a4d92247a 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -10,7 +10,6 @@ from azpysdk.changelog import ( changelog, REPO_ROOT, - _CHRONUS_MODULE_PATH, _CHRONUS_BIN_PATH, _CHANGE_KINDS, _FALLBACK_CHANGE_KINDS, @@ -367,19 +366,6 @@ def test_outside_repo(self): assert c._detect_package_from_cwd() is None -# --------------------------------------------------------------------------- -# Error handling tests -# --------------------------------------------------------------------------- - - -class TestChangelogErrors: - """Verify error handling when prerequisites are missing.""" - - # Note: there is no longer an "npx not found" error path — chronus is - # invoked via the pinned binary under .github/chronus/node_modules/.bin. - # Missing-binary scenarios are exercised by TestEnsureChronusInstalled. - - # --------------------------------------------------------------------------- # Chronus installation check tests # --------------------------------------------------------------------------- From 44e7c4ed8e3796b9afd80df71267d26ce303480b Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 23 Apr 2026 15:14:20 -0700 Subject: [PATCH 11/11] reuse discover_targetd_packagfes Co-authored-by: Copilot --- .../azure-sdk-tools/azpysdk/changelog.py | 105 +++++++++--------- .../tests/test_changelog_commands.py | 73 ++++++++++-- 2 files changed, 111 insertions(+), 67 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/changelog.py b/eng/tools/azure-sdk-tools/azpysdk/changelog.py index 8e562e0403e3..a476f330b5ea 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/changelog.py +++ b/eng/tools/azure-sdk-tools/azpysdk/changelog.py @@ -6,7 +6,9 @@ from typing import Optional, List from .Check import Check, REPO_ROOT +from ci_tools.functions import discover_targeted_packages from ci_tools.logging import logger +from ci_tools.parsing import ParsedSetup # The expected Chronus package name and on-disk install location. # Chronus is pinned as a dev dependency in .github/chronus/package.json with @@ -211,13 +213,17 @@ def _ensure_chronus_installed(self) -> None: ) raise SystemExit(1) - def _detect_package_from_cwd(self) -> Optional[str]: - """If CWD is inside a package directory (``sdk//``), - return the Chronus package **name** (the directory basename, e.g. - ``azure-core``). Otherwise return ``None``. + def _find_package_root_from_cwd(self) -> Optional[str]: + """Find the package root directory when CWD is at or below ``sdk//``. - The chronus config uses the pattern ``sdk/*/*`` for packages, so we - look for CWD being at or below ``/sdk//``. + Walks up from the current directory to locate the package root, + unlike ``get_targeted_directories(target=".")`` which only works + when CWD is exactly the package root. This lets developers run + changelog commands from subdirectories such as + ``sdk/core/azure-core/tests/``. + + Returns the absolute path to the package root, or ``None`` if CWD + is not inside an ``sdk//`` tree. """ try: cwd = os.path.abspath(os.getcwd()) @@ -227,40 +233,38 @@ def _detect_package_from_cwd(self) -> Optional[str]: # On Windows, relpath raises ValueError when paths are on different drives return None - # rel should start with "sdk//" (at least 3 components) parts = rel.replace("\\", "/").split("/") if len(parts) >= 3 and parts[0] == "sdk": - # Return the package name (third component, e.g. "azure-core") - return parts[2] + return os.path.join(repo, parts[0], parts[1], parts[2]) return None - def _resolve_package_name(self, package: str) -> str: - """Resolve a user-supplied package argument to a Chronus package name. - - Accepts either: - - A package name directly (e.g. ``azure-core``) — returned as-is. - - A relative or absolute path (e.g. ``sdk/core/azure-core``, - ``.\\sdk\\core\\azure-core\\``) — resolved to the package name. + def _resolve_package(self, package_arg: Optional[str]) -> Optional[str]: + """Resolve a package argument or CWD to a Chronus package name. - Chronus identifies packages by name (the directory basename under - ``sdk//``), not by path. + Uses ``discover_targeted_packages`` — the same discovery function + that powers ``get_targeted_directories`` — to locate packages by + path or bare name. When *package_arg* is ``None``, the method + detects the package from CWD by walking up to the nearest + ``sdk//`` directory. """ - # If the path is absolute or starts with '.' resolve it relative to repo root - if os.path.isabs(package) or package.startswith("."): - try: - abs_path = os.path.abspath(os.path.join(os.getcwd(), package)) - repo = os.path.abspath(REPO_ROOT) - package = os.path.relpath(abs_path, repo) - except ValueError: - pass - # Normalize separators and strip trailing slashes - package = package.replace("\\", "/").strip("/") - # If it looks like a path (sdk//...), extract the name - parts = package.split("/") - if len(parts) >= 3 and parts[0] == "sdk": - return parts[2] - # Otherwise assume it's already a package name - return package + if package_arg: + found = discover_targeted_packages(package_arg, REPO_ROOT) + if found: + try: + return ParsedSetup.from_path(found[0]).name + except Exception: + return os.path.basename(found[0]) + # Not found by discovery — pass through as-is + return package_arg + + # No explicit package — detect from CWD + pkg_root = self._find_package_root_from_cwd() + if pkg_root is None: + return None + try: + return ParsedSetup.from_path(pkg_root).name + except Exception: + return os.path.basename(os.path.normpath(pkg_root)) def _run_chronus(self, chronus_args: List[str]) -> int: """Run a chronus command from the repository root. @@ -293,13 +297,10 @@ def _run_add(self, args: argparse.Namespace) -> int: ``azpysdk changelog add --kind breaking -m "Removed foo API"``). """ chronus_args = ["add"] - package = args.package - if package: - package = self._resolve_package_name(package) - else: - package = self._detect_package_from_cwd() - if package: - logger.info(f"Detected package from current directory: {package}") + detected_from_cwd = not args.package + package = self._resolve_package(args.package) + if package and detected_from_cwd: + logger.info(f"Detected package from current directory: {package}") if package: chronus_args.append(package) @@ -323,13 +324,10 @@ def _run_create(self, args: argparse.Namespace) -> int: via ``--package`` so only that package's changelog is generated. """ chronus_args = ["changelog"] - package = args.package - if package: - package = self._resolve_package_name(package) - else: - package = self._detect_package_from_cwd() - if package: - logger.info(f"Detected package from current directory: {package}") + detected_from_cwd = not args.package + package = self._resolve_package(args.package) + if package and detected_from_cwd: + logger.info(f"Detected package from current directory: {package}") if not package: logger.error( "No package specified and could not detect one from the current directory.\n" @@ -356,13 +354,10 @@ def _run_status(self, args: argparse.Namespace) -> int: via ``--only`` so only that package's status is shown. """ chronus_args = ["status"] - package = args.package - if package: - package = self._resolve_package_name(package) - else: - package = self._detect_package_from_cwd() - if package: - logger.info(f"Detected package from current directory: {package}") + detected_from_cwd = not args.package + package = self._resolve_package(args.package) + if package and detected_from_cwd: + logger.info(f"Detected package from current directory: {package}") if package: chronus_args.extend(["--only", package]) return self._run_chronus(chronus_args) diff --git a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py index 995a4d92247a..a62d0158bb03 100644 --- a/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py +++ b/eng/tools/azure-sdk-tools/tests/test_changelog_commands.py @@ -3,6 +3,7 @@ import argparse import os import sys +from types import SimpleNamespace from unittest.mock import patch, MagicMock, call import pytest @@ -303,7 +304,8 @@ def test_create_calls_chronus_changelog(self, mock_call, _mock_installed): def test_status_calls_chronus_status(self, mock_call, _mock_installed): parser = _build_parser() args = parser.parse_args(["changelog", "status"]) - result = args.func(args) + with patch("os.getcwd", return_value=REPO_ROOT): + result = args.func(args) assert result == 0 cmd = mock_call.call_args[0][0] assert cmd == [_CHRONUS_BIN, "status"] @@ -328,42 +330,89 @@ def test_nonzero_exit_code_propagated(self, mock_call, _mock_installed): # --------------------------------------------------------------------------- -# CWD detection unit tests +# Package resolution unit tests # --------------------------------------------------------------------------- -class TestDetectPackageFromCwd: - """Test the _detect_package_from_cwd helper directly.""" +class TestFindPackageRootFromCwd: + """Test the _find_package_root_from_cwd helper directly.""" def test_at_package_root(self): c = changelog() - with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "core", "azure-core")): - assert c._detect_package_from_cwd() == "azure-core" + pkg_root = os.path.join(REPO_ROOT, "sdk", "core", "azure-core") + with patch("os.getcwd", return_value=pkg_root): + assert c._find_package_root_from_cwd() == pkg_root def test_inside_package_subdir(self): c = changelog() - with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "core", "azure-core", "azure", "core")): - assert c._detect_package_from_cwd() == "azure-core" + pkg_root = os.path.join(REPO_ROOT, "sdk", "core", "azure-core") + with patch("os.getcwd", return_value=os.path.join(pkg_root, "azure", "core")): + assert c._find_package_root_from_cwd() == pkg_root def test_at_repo_root(self): c = changelog() with patch("os.getcwd", return_value=REPO_ROOT): - assert c._detect_package_from_cwd() is None + assert c._find_package_root_from_cwd() is None def test_at_sdk_dir(self): c = changelog() with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk")): - assert c._detect_package_from_cwd() is None + assert c._find_package_root_from_cwd() is None def test_at_service_dir(self): c = changelog() with patch("os.getcwd", return_value=os.path.join(REPO_ROOT, "sdk", "storage")): - assert c._detect_package_from_cwd() is None + assert c._find_package_root_from_cwd() is None def test_outside_repo(self): c = changelog() with patch("os.getcwd", return_value="/tmp"): - assert c._detect_package_from_cwd() is None + assert c._find_package_root_from_cwd() is None + + +class TestResolvePackage: + """Test the _resolve_package helper that uses discover_targeted_packages.""" + + @patch("azpysdk.changelog.ParsedSetup") + @patch("azpysdk.changelog.discover_targeted_packages") + def test_explicit_path_uses_discover(self, mock_discover, mock_parsed_setup): + mock_discover.return_value = [os.path.join(REPO_ROOT, "sdk", "core", "azure-core")] + mock_parsed_setup.from_path.return_value = SimpleNamespace(name="azure-core") + c = changelog() + result = c._resolve_package("sdk/core/azure-core") + assert result == "azure-core" + mock_discover.assert_called_once_with("sdk/core/azure-core", REPO_ROOT) + + @patch("azpysdk.changelog.ParsedSetup") + @patch("azpysdk.changelog.discover_targeted_packages") + def test_bare_name_uses_discover(self, mock_discover, mock_parsed_setup): + mock_discover.return_value = [os.path.join(REPO_ROOT, "sdk", "core", "azure-core")] + mock_parsed_setup.from_path.return_value = SimpleNamespace(name="azure-core") + c = changelog() + result = c._resolve_package("azure-core") + assert result == "azure-core" + mock_discover.assert_called_once_with("azure-core", REPO_ROOT) + + @patch("azpysdk.changelog.discover_targeted_packages") + def test_bare_name_passthrough_when_not_discovered(self, mock_discover): + mock_discover.return_value = [] + c = changelog() + result = c._resolve_package("some-nonexistent-package-name") + assert result == "some-nonexistent-package-name" + + @patch("azpysdk.changelog.ParsedSetup") + def test_cwd_detection_uses_parsed_setup(self, mock_parsed_setup): + mock_parsed_setup.from_path.return_value = SimpleNamespace(name="azure-storage-blob") + c = changelog() + pkg_dir = os.path.join(REPO_ROOT, "sdk", "storage", "azure-storage-blob") + with patch("os.getcwd", return_value=pkg_dir): + result = c._resolve_package(None) + assert result == "azure-storage-blob" + + def test_cwd_at_repo_root_returns_none(self): + c = changelog() + with patch("os.getcwd", return_value=REPO_ROOT): + assert c._resolve_package(None) is None # ---------------------------------------------------------------------------