diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9ddc62b..aba658068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI: smoke tests in `build-release.yml`'s `build-and-test` job (Linux x86_64, Linux arm64, Windows) are now gated to promotion boundaries (tag/schedule/dispatch) instead of running on every push to main. Push-time smoke duplicated the merge-time smoke gate in `ci-integration.yml` and burned ~15 redundant codex-binary downloads/day. Tag-cut releases still run smoke as a pre-ship gate; nightly catches upstream codex URL drift; merge-time still gates merges into main. (#878) - CI docs: clarify that branch-protection ruleset must store the check-run name (`gate`), not the workflow display string (`Merge Gate / gate`); document the merge-gate aggregator in `cicd.instructions.md` and mark the legacy stub workflow as deprecated. +### Fixed + +- `apm install` (user scope): `init_link_resolver` now scopes `discover_primitives` to `~/.apm/` instead of `~/`, preventing recursive-glob across the entire home directory. Fixes #830 (#850) + ### Removed - CI: deleted `ci-integration-pr-stub.yml`. The four stubs were a holdover from the pre-merge-gate model where branch protection required each Tier 2 check name directly. After #867, branch protection requires only `gate`, so the stubs are dead weight. Reduced `EXPECTED_CHECKS` in `merge-gate.yml` to just `Build & Test (Linux)`. diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index fa7c92ee5..1fc42f91e 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -312,7 +312,13 @@ def init_link_resolver(self, package_info, project_root: Path) -> None: """Initialise and register the link resolver for a package.""" self.link_resolver = UnifiedLinkResolver(project_root) try: - primitives = discover_primitives(package_info.install_path) + scan_root = package_info.install_path + # When install_path is $HOME (user-scope local package), + # only scan the .apm/ subdirectory to avoid recursive- + # globbing the entire home tree. See issue #830. + if scan_root == Path.home(): + scan_root = scan_root / ".apm" + primitives = discover_primitives(scan_root) self.link_resolver.register_contexts(primitives) except Exception: self.link_resolver = None diff --git a/tests/unit/integration/test_base_integrator.py b/tests/unit/integration/test_base_integrator.py index 8737d2dbe..6e5a95500 100644 --- a/tests/unit/integration/test_base_integrator.py +++ b/tests/unit/integration/test_base_integrator.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.primitives.discovery import discover_primitives # --------------------------------------------------------------------------- @@ -592,3 +593,41 @@ class TestShouldIntegrate: def test_always_returns_true(self): bi = BaseIntegrator() assert bi.should_integrate(Path("/any/path")) is True + + +# --------------------------------------------------------------------------- +# init_link_resolver — home-directory scoping (#830) +# --------------------------------------------------------------------------- + +class TestInitLinkResolverHomeScoping: + """When install_path is $HOME, init_link_resolver must scope + discover_primitives to ~/.apm/ to avoid recursive-globbing the + entire home directory. See issue #830.""" + + @patch("apm_cli.integration.base_integrator.discover_primitives") + @patch("apm_cli.integration.base_integrator.UnifiedLinkResolver") + def test_scopes_to_apm_subdir_when_install_path_is_home( + self, mock_resolver_cls, mock_discover + ): + mock_discover.return_value = [] + bi = BaseIntegrator() + pkg_info = MagicMock() + pkg_info.install_path = Path.home() + + bi.init_link_resolver(pkg_info, Path.home()) + + mock_discover.assert_called_once_with(Path.home() / ".apm") + + @patch("apm_cli.integration.base_integrator.discover_primitives") + @patch("apm_cli.integration.base_integrator.UnifiedLinkResolver") + def test_uses_install_path_when_not_home( + self, mock_resolver_cls, mock_discover, tmp_path + ): + mock_discover.return_value = [] + bi = BaseIntegrator() + pkg_info = MagicMock() + pkg_info.install_path = tmp_path + + bi.init_link_resolver(pkg_info, tmp_path) + + mock_discover.assert_called_once_with(tmp_path) diff --git a/tests/unit/test_local_content_install.py b/tests/unit/test_local_content_install.py index bf1ba4148..21d3ade5f 100644 --- a/tests/unit/test_local_content_install.py +++ b/tests/unit/test_local_content_install.py @@ -174,7 +174,7 @@ def test_skips_root_skill_md(self, mock_integrate, tmp_path): @patch("apm_cli.install.services.integrate_package_primitives") def test_package_info_install_path_is_project_root(self, mock_integrate, tmp_path): - """The synthetic PackageInfo must point to project_root, not .apm/.""" + """The synthetic PackageInfo must point to project_root at project scope.""" mock_integrate.return_value = _zero_counters() _integrate_local_content(tmp_path, **_make_integrators()) @@ -182,6 +182,22 @@ def test_package_info_install_path_is_project_root(self, mock_integrate, tmp_pat package_info = mock_integrate.call_args[0][0] assert package_info.install_path == tmp_path + @patch("apm_cli.install.services.integrate_package_primitives") + def test_user_scope_install_path_stays_project_root(self, mock_integrate, tmp_path): + """At user scope, install_path must remain project_root so that + integrators can still find /.apm//. + The recursive-glob fix lives in init_link_resolver, not here. + Regression check for #830.""" + from apm_cli.core.scope import InstallScope + + mock_integrate.return_value = _zero_counters() + (tmp_path / ".apm").mkdir(exist_ok=True) + + _integrate_local_content(tmp_path, **_make_integrators(), scope=InstallScope.USER) + + package_info = mock_integrate.call_args[0][0] + assert package_info.install_path == tmp_path + @patch("apm_cli.install.services.integrate_package_primitives") def test_returns_zero_counters_when_nothing_deployed(self, mock_integrate, tmp_path): """When nothing is deployed the result counters are all zero."""