From aca986e9000607435a6e1fff3211ccc1157e593f Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:44:44 -0700 Subject: [PATCH 1/2] fix: preserve updater cache ownership and source-bound discovery Cover embedded modules with their exact owning Git cache while retaining independent selective module refresh and dependency installation. Resolve cached namespace includes from source-bound contexts, keep checks nonpersistent, and render safe local labels without diagnostic module rows. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- AGENTS.md | 6 + amplifier_app_cli/commands/update.py | 117 ++++--- amplifier_app_cli/utils/include_graph.py | 187 ++++++++--- tests/test_bundle_commands.py | 20 +- tests/test_declared_module_updates.py | 381 ++++++++++++++++++++++- tests/test_transitive_include_updates.py | 179 +++++++++++ tests/test_update_reporting.py | 47 +++ 7 files changed, 845 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index faad9edf..35d08b07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,12 @@ refresh/install them through Foundation's selective module update, never load them as bundles. Missing manifests are incomplete discovery, not current. Convert explicit file URIs to native paths with one decode; keep ordinary paths literal. Keep `--check-only --force` read-only; run `tests/test_declared_module_updates.py`. +Hide local module rows only when their declaring Git manifest and readable code +are proven inside that exact cache with no explicit override; retain an owner row +even when repository dedup omitted it. An explicit Git module action may cover +that cache; never replace its install. Reuse source-bound Foundation namespaces +and extension probing for cached includes. Checking uses a shared nonpersistent +registry; empty discovery diagnostics belong to the owner, never a module row. ## Persisted reminder display diff --git a/amplifier_app_cli/commands/update.py b/amplifier_app_cli/commands/update.py index f3d25bcc..7a00027b 100644 --- a/amplifier_app_cli/commands/update.py +++ b/amplifier_app_cli/commands/update.py @@ -15,6 +15,7 @@ from rich.table import Table from rich.text import Text +from amplifier_foundation import BundleRegistry from amplifier_foundation.updates import BundleStatus as _BundleStatus from amplifier_foundation.paths.resolution import get_amplifier_home @@ -25,12 +26,12 @@ # would drift from the one users see everywhere else. from ..lib.bundle_loader.prepare import _extract_behavior_name from ..lib.settings import AppSettings -from ..paths import create_bundle_registry from ..paths import create_config_manager from ..utils.atomic_write import atomic_write_json from ..utils.display import create_sha_text from ..utils.error_format import escape_markup from ..utils.file_uri import file_uri_path_value as _file_uri_path_value +from ..utils.include_graph import local_source_path from ..utils.settings_manager import save_update_last_check from ..utils.source_status import check_all_sources from ..utils.update_executor import execute_updates @@ -234,7 +235,7 @@ def _classify_source_status( if getattr(source, "error", None): return "check_failed" - file_path = _file_uri_path(getattr(source, "source_uri", "")) + file_path = local_source_path(getattr(source, "source_uri", "")) if file_path is not None: if not file_path.exists(): return "missing_path" @@ -585,7 +586,7 @@ def _strip_uri_fragment(uri: str) -> str: def _is_bundle_source_key(key: str) -> bool: """Whether a key represents a source rather than a registry alias.""" - return key.startswith( + return local_source_path(key) is not None or key.startswith( (_INVALID_APP_SOURCE_PREFIX, "git+", "zip+", "file://", "http://", "https://") ) @@ -776,6 +777,17 @@ def _bundle_row_sort_key( return (label.lower(), 0, label.lower()) +def _result_display_names(results: dict[str, "BundleStatus"]) -> dict[str, str]: + """One safe label plan for report headings, progress, and completion.""" + labels = _bundle_display_names(results) + for key, status in results.items(): + if isinstance(status, DeclaredModuleStatus): + qualifier = "local" if local_source_path(key) is not None else labels[key] + labels[key] = f"{status.module_id} ({qualifier})" + _resolve_remaining_label_collisions(labels) + return labels + + def _extract_module_name_from_uri(source_uri: str) -> str: """Extract a clean module name from a source URI. @@ -846,6 +858,8 @@ def _collect_unified_modules( if bundle_results: for bundle_name, bundle_status in bundle_results.items(): for source in bundle_status.sources: + if not source.source_uri: + continue # Owner-level diagnostics are not module sources. # Skip any bundle repo (identified by normalized URI match) if _normalize_git_url(source.source_uri) in bundle_source_uris: continue @@ -1056,8 +1070,8 @@ async def _check_all_bundle_status() -> dict[str, "BundleStatus"]: from ..lib.bundle_loader.discovery import WELL_KNOWN_BUNDLES - discovery = AppBundleDiscovery() - registry = create_bundle_registry() + registry = BundleRegistry(home=get_amplifier_home(), persist=False) + discovery = AppBundleDiscovery(registry=registry) settings = AppSettings() bundle_overrides = settings.get_bundle_sources() if bundle_overrides: @@ -1217,7 +1231,11 @@ async def _check_bundle_uri(bundle_name: str, uri: str) -> BundleStatus: # sources/git.py:697-709), the only thing that would ever move it is this # command. Skipping it here is what let a transitively-included @main # bundle sit indefinitely behind a green "All sources up to date". - results.update(await _check_transitive_bundle_status(set(seeds.values()), results)) + results.update( + await _check_transitive_bundle_status( + set(seeds.values()), results, registry=registry + ) + ) from ..utils.include_graph import collect_declared_module_sources @@ -1244,7 +1262,38 @@ async def _check_bundle_uri(bundle_name: str, uri: str) -> BundleStatus: if root_discovery_failed: failures.update({label: "Incomplete cached module discovery" for label in roots}) + async def ensure_owner(uri: str, parent: str, root: str) -> BundleStatus: + owner_uri = _strip_uri_fragment(uri) + owner = next( + ( + status + for status in results.values() + if _strip_uri_fragment(status.bundle_source or "") == owner_uri + ), + None, + ) + if owner is None: + try: + owner = await _check_bundle_uri(owner_uri, owner_uri) + except Exception as exc: # One bad owner must not hide independent actions. + owner = _failed_bundle_status(owner_uri, owner_uri, exc) + owner = TransitiveBundleStatus( + bundle_name=owner_uri, + bundle_source=owner_uri, + sources=owner.sources, + included_by=parent, + included_under=root, + is_pinned=any(source.is_pinned for source in owner.sources), + ) + results[owner_uri] = owner + # An explicit Git module can also own this exact cache. Keep its + # selective dependency-install action instead of replacing it with a + # cache-only refresh, regardless of declaration order. + return owner + for uri, declaration in declared.items(): + if declaration.embedded_in: + continue try: parsed = parse_uri(uri) if git_handler.can_handle(parsed): @@ -1273,25 +1322,22 @@ async def _check_bundle_uri(bundle_name: str, uri: str) -> BundleStatus: is_pinned=source_status.is_pinned, ) + # Decide embedded coverage after all explicit Git module actions exist. + # Otherwise declaration order can create a redundant cache-only refresh. + for declaration in declared.values(): + if declaration.embedded_in: + await ensure_owner( + declaration.embedded_in, declaration.declared_by, declaration.root + ) + for root, error in failures.items(): root_uri = roots[root] - owner = next( - ( - status - for status in results.values() - if not isinstance(status, DeclaredModuleStatus) - and status.bundle_source - and _strip_uri_fragment(status.bundle_source) - == _strip_uri_fragment(root_uri) - ), - None, - ) - if owner is not None: - owner.sources.append( - SourceStatus( - source_uri="", is_cached=False, has_update=None, error=error - ) + owner = await ensure_owner(root_uri, root, root) + owner.sources.append( + SourceStatus( + source_uri="", is_cached=False, has_update=None, error=error ) + ) return results @@ -1299,6 +1345,8 @@ async def _check_bundle_uri(bundle_name: str, uri: str) -> BundleStatus: async def _check_transitive_bundle_status( checked_uris: set[str], direct_results: dict[str, "BundleStatus"], + *, + registry: BundleRegistry | None = None, ) -> dict[str, "BundleStatus"]: """Status-check every git source reachable via ``includes:`` from *checked_uris*. @@ -1315,7 +1363,9 @@ async def _check_transitive_bundle_status( return {} cache_dir = get_amplifier_home() / "cache" - registry = create_bundle_registry() + if registry is None: + registry = BundleRegistry(home=get_amplifier_home(), persist=False) + AppBundleDiscovery(registry=registry) bundle_overrides = AppSettings().get_bundle_sources() if bundle_overrides: from ..lib.bundle_loader.prepare import _build_include_source_resolver @@ -1672,7 +1722,7 @@ def _show_concise_report( table.add_column("Remote", style="dim", justify="right") table.add_column("Status", justify="center") - display_names = _bundle_display_names(bundle_results.keys()) + display_names = _result_display_names(bundle_results) bundle_plan = bundle_plan or _bundle_report_plan(bundle_results) for bundle_name in sorted( @@ -1696,9 +1746,7 @@ def _show_concise_report( ) # Add "(active)" marker if this is the active bundle - display_name = display_names[bundle_name] - if isinstance(bundle_status, DeclaredModuleStatus): - display_name = f"{bundle_status.module_id} ({display_name})" + display_name = escape_markup(display_names[bundle_name]) if active_bundle and bundle_name == active_bundle: display_name = f"{display_name} [green](active)[/green]" @@ -1905,7 +1953,7 @@ def _show_verbose_report( # === BUNDLES === if bundle_results: active_bundle = _get_active_bundle_name() - display_names = _bundle_display_names(bundle_results.keys()) + display_names = _result_display_names(bundle_results) bundle_plan = bundle_plan or _bundle_report_plan(bundle_results) for bundle_name in sorted( bundle_results.keys(), @@ -1921,9 +1969,7 @@ def _show_verbose_report( heading = ( "Module" if isinstance(status, DeclaredModuleStatus) else "Bundle" ) - display_name = display_names[bundle_name] - if isinstance(status, DeclaredModuleStatus): - display_name = f"{status.module_id} ({display_name})" + display_name = escape_markup(display_names[bundle_name]) console.print( f"[bold cyan]{heading}: {display_name}" f"{title_suffix}[/bold cyan]" @@ -1934,6 +1980,8 @@ def _show_verbose_report( console.print() for source in sorted(status.sources, key=lambda x: x.source_uri): + if not source.source_uri: + continue # Owner already shows this diagnostic. # Extract module name from source URI for cleaner display source_name = source.source_uri if "/" in source_name: @@ -2257,10 +2305,7 @@ async def _check_sources(): bundle_results = asyncio.run(_check_all_bundle_status()) _deduplicate_declared_module_caches(report, bundle_results) bundle_plan = _bundle_report_plan(bundle_results) - bundle_labels = _bundle_display_names(bundle_results.keys()) - for name, status in bundle_results.items(): - if isinstance(status, DeclaredModuleStatus): - bundle_labels[name] = f"{status.module_id} ({bundle_labels[name]})" + bundle_labels = _result_display_names(bundle_results) has_bundle_updates = ( any(s.has_updates for s in bundle_results.values()) if bundle_results else False ) @@ -2393,7 +2438,7 @@ async def _check_sources(): def _on_update_progress(name: str, phase: str) -> None: label = _format_update_progress(name, phase) - spinner.update(f"[dim]{label}[/dim]") + spinner.update(f"[dim]{escape_markup(label)}[/dim]") try: result = asyncio.run( diff --git a/amplifier_app_cli/utils/include_graph.py b/amplifier_app_cli/utils/include_graph.py index 03b5018c..e16877aa 100644 --- a/amplifier_app_cli/utils/include_graph.py +++ b/amplifier_app_cli/utils/include_graph.py @@ -51,7 +51,8 @@ if TYPE_CHECKING: from amplifier_foundation import Bundle, BundleRegistry - from amplifier_foundation.sources.protocol import SourceStatus + from amplifier_foundation.registry import _NamespaceSource + from amplifier_foundation.sources.protocol import ResolvedSource, SourceStatus logger = logging.getLogger(__name__) @@ -123,43 +124,80 @@ def has_update(self) -> bool: return self.status.has_update is True -def _local_path_for(uri: str, cache_dir: Path) -> Path | None: - """Best-available on-disk path for *uri*, WITHOUT touching the network. +def local_source_path(uri: str) -> Path | None: + """Decode explicit file URIs once; don't mistake registry aliases for paths.""" + if uri.lower().startswith("file:"): + native = file_uri_path_value(uri, windows=os.name == "nt") + return Path(native) if native is not None else None + if Path(uri).is_absolute() or uri.startswith(("./", "../", ".\\", "..\\")): + return Path(uri) + return None + - Returns None when the source is not a readable local/cached thing -- - which is the correct answer for a source whose cache has been deleted. - Resolving it would clone, and a clone here would report a missing cache - as healthy. - """ +def _cached_source_for( + uri: str, cache_dir: Path, registry: BundleRegistry +) -> ResolvedSource | None: + """Reconstruct Foundation's ResolvedSource from existing files, never resolve.""" from amplifier_foundation.paths.resolution import parse_uri + from amplifier_foundation.sources.file import FileSourceHandler from amplifier_foundation.sources.git import GitSourceHandler + from amplifier_foundation.sources.protocol import ResolvedSource - try: - parsed = parse_uri(uri) - except Exception: # noqa: BLE001 - an unparseable URI is simply not walkable - return None - - git_handler = GitSourceHandler() - if git_handler.can_handle(parsed): - cache_path = git_handler._get_cache_path(parsed, cache_dir) - if not cache_path.exists(): + parsed = parse_uri(uri) + handler = GitSourceHandler() + if handler.can_handle(parsed): + source_root = handler._get_cache_path(parsed, cache_dir) + if source_root.resolve() != Path(os.path.abspath(source_root)): + return None # A sibling checkout is not this source's cache. + path = source_root / parsed.subpath if parsed.subpath else source_root + path = registry._find_resource_path(path) + else: + path = local_source_path(uri) + if path is None: return None - active = cache_path / parsed.subpath if parsed.subpath else cache_path - return active if active.exists() else None - - if parsed.is_file: - if uri.lower().startswith("file:"): - native = file_uri_path_value(uri, windows=os.name == "nt") - if native is None: - return None - path = Path(native) - else: - path = Path(parsed.path) - if parsed.subpath: - path = path / parsed.subpath - return path if path.exists() else None - - return None + base = path.resolve() + subpath = parsed.subpath if uri.lower().startswith("file:") else "" + path = base / subpath if subpath else base + path = registry._find_resource_path(path) + if path is None: + return None + source_root = ( + base + if subpath + else FileSourceHandler()._find_source_root(path, cache_dir) + ) + if path is not None and path.is_dir(): + # Validate the actual file the loader would choose, not only its parent. + path = registry._find_nearest_bundle_file(path, path) + if path is None or not path.resolve().is_relative_to(source_root.resolve()): + return None + return ResolvedSource(active_path=path, source_root=source_root) + + +async def _cached_namespace_sources( + uri: str, resolved: ResolvedSource, bundle: Bundle, registry: BundleRegistry +) -> dict[str, _NamespaceSource]: + """Bind containing-root then self exactly as Foundation does, without loading includes.""" + from amplifier_foundation.registry import _NamespaceSource + + path = resolved.active_path + source_root = resolved.source_root.resolve() + search_start = path.parent.parent if path.is_file() else path.parent + containing = None + if search_start.resolve().is_relative_to(source_root): + nearest = registry._find_nearest_bundle_file(search_start, source_root) + if nearest is not None: + if not nearest.resolve().is_relative_to(source_root): + raise ValueError("Escaped containing manifest") + containing = await registry._load_from_path(nearest) + bindings = {} + for owner in (containing, bundle): + if owner is not None and owner.name and owner.base_path: + base = (owner.base_path / (owner.namespace_root or ".")).resolve() + if not base.is_relative_to(source_root): + raise ValueError("Escaped namespace root") + bindings[owner.name] = _NamespaceSource(uri, base, source_root) + return bindings def _is_git_uri(uri: str) -> bool: @@ -208,12 +246,16 @@ def failed(root: str) -> None: continue try: - path = _local_path_for(uri, cache_dir) - if path is None: + resolved_source = _cached_source_for(uri, cache_dir, registry) + if resolved_source is None: failed(root_label) yield root_label, label, uri, None continue + path = resolved_source.active_path bundle = await registry._load_from_path(path) + namespace_sources = await _cached_namespace_sources( + uri, resolved_source, bundle, registry + ) except Exception: # noqa: BLE001 - an unreadable bundle is not fatal failed(root_label) yield root_label, label, uri, None @@ -228,7 +270,27 @@ def failed(root: str) -> None: if not spec: failed(root_label) continue - resolved = registry._resolve_include_source(spec) + # Foreign namespaces may have no local_path (or a stale one). + # Rebuild their selected cached context rather than guessing a + # checkout-relative path or modifying shared registrations. + bindings = dict(namespace_sources) + if ":" in spec and "://" not in spec: + namespace = spec.split(":", 1)[0] + if namespace not in bindings: + foreign_uri = registry.find(namespace) + if foreign_uri: + foreign = _cached_source_for(foreign_uri, cache_dir, registry) + if foreign is None: + raise ValueError("Missing cached namespace manifest") + owner = await registry._load_from_path(foreign.active_path) + foreign_bindings = await _cached_namespace_sources( + foreign_uri, foreign, owner, registry + ) + if owner.name in foreign_bindings: + bindings[namespace] = foreign_bindings[owner.name] + resolved = registry._resolve_include_source( + spec, namespace_sources=bindings + ) if not resolved: failed(root_label) continue @@ -287,6 +349,38 @@ class DeclaredModuleSource: declared_by: str root: str overridden_sources: tuple[str, ...] = () + declaring_uri: str = "" + embedded_in: str | None = None + + +def _embedded_owner( + uri: str, declaring_uri: str, cache_dir: Path, registry: BundleRegistry +) -> str | None: + """Prove local code and its declaring manifest belong to one exact Git cache.""" + from amplifier_foundation.paths.resolution import parse_uri + from amplifier_foundation.sources.git import GitSourceHandler + + path = local_source_path(uri) + if path is None or not _is_git_uri(declaring_uri): + return None + try: + root = GitSourceHandler()._get_cache_path(parse_uri(declaring_uri), cache_dir) + manifest = _cached_source_for(declaring_uri, cache_dir, registry) + if manifest is None or not root.is_dir() or not path.is_dir(): + return None + # Do not hide symlinked or escaping paths as repository-owned content. + canonical = path.resolve(strict=True) + if canonical != Path(os.path.abspath(path)): + return None + if not canonical.is_relative_to(root.resolve()): + return None + if not manifest.active_path.resolve().is_relative_to(root.resolve()): + return None + if not os.access(path, os.R_OK | os.X_OK): + return None + except (OSError, ValueError): + return None + return strip_uri_fragment(declaring_uri) async def collect_declared_module_sources( @@ -305,7 +399,7 @@ async def collect_declared_module_sources( found: dict[str, DeclaredModuleSource] = {} failures: dict[str, str] = {} overrides = overrides or {} - async for root, parent, _, bundle in _iter_cached_bundles( + async for root, parent, declaring_uri, bundle in _iter_cached_bundles( roots, registry=registry, cache_dir=cache_dir, failures=failures ): if bundle is None: @@ -327,9 +421,23 @@ async def collect_declared_module_sources( uri = overrides.get(module_id, declaration.get("source")) if not isinstance(uri, str) or not uri: continue - entry = found.setdefault( - uri, DeclaredModuleSource(module_id, bundle.name or parent, root) + embedded_in = ( + None + if module_id in overrides + else _embedded_owner(uri, declaring_uri, cache_dir, registry) ) + entry = found.get(uri) + if entry is None: + entry = DeclaredModuleSource( + module_id, + bundle.name or parent, + root, + declaring_uri=declaring_uri, + embedded_in=embedded_in, + ) + elif entry.embedded_in != embedded_in: + entry = replace(entry, embedded_in=None) + found[uri] = entry original = declaration.get("source") if ( isinstance(original, str) @@ -416,6 +524,7 @@ async def refresh_transitive_source(uri: str, *, cache_dir: Path) -> None: "check_transitive_sources", "collect_declared_module_sources", "collect_transitive_git_sources", + "local_source_path", "refresh_transitive_source", "strip_uri_fragment", "transitive_statuses_for", diff --git a/tests/test_bundle_commands.py b/tests/test_bundle_commands.py index d10619f2..0c532c92 100644 --- a/tests/test_bundle_commands.py +++ b/tests/test_bundle_commands.py @@ -212,9 +212,9 @@ async def test_global_update_checks_app_only_sources_by_exact_uri(monkeypatch): monkeypatch.setattr( update_module, "AppBundleDiscovery", - lambda: SimpleNamespace(list_cached_root_bundles=lambda: []), + lambda **kwargs: SimpleNamespace(list_cached_root_bundles=lambda: []), ) - monkeypatch.setattr(update_module, "create_bundle_registry", MagicMock()) + monkeypatch.setattr(update_module, "BundleRegistry", MagicMock()) monkeypatch.setattr( update_module, "AppSettings", @@ -257,11 +257,11 @@ def find_bundle(bundle_name): monkeypatch.setattr( update_module, "AppBundleDiscovery", - lambda: SimpleNamespace( + lambda **kwargs: SimpleNamespace( list_cached_root_bundles=lambda: ["broken", working_bundle] ), ) - monkeypatch.setattr(update_module, "create_bundle_registry", lambda: registry) + monkeypatch.setattr(update_module, "BundleRegistry", lambda **kwargs: registry) monkeypatch.setattr( update_module, "AppSettings", @@ -302,9 +302,9 @@ async def test_global_update_reports_direct_app_check_failure_by_exact_uri(monke monkeypatch.setattr( update_module, "AppBundleDiscovery", - lambda: SimpleNamespace(list_cached_root_bundles=lambda: []), + lambda **kwargs: SimpleNamespace(list_cached_root_bundles=lambda: []), ) - monkeypatch.setattr(update_module, "create_bundle_registry", MagicMock()) + monkeypatch.setattr(update_module, "BundleRegistry", MagicMock()) monkeypatch.setattr( update_module, "AppSettings", @@ -422,9 +422,9 @@ async def test_global_update_collapses_an_app_subdirectory_into_its_root_bundle( monkeypatch.setattr( update_module, "AppBundleDiscovery", - lambda: SimpleNamespace(list_cached_root_bundles=lambda: ["team"]), + lambda **kwargs: SimpleNamespace(list_cached_root_bundles=lambda: ["team"]), ) - monkeypatch.setattr(update_module, "create_bundle_registry", lambda: registry) + monkeypatch.setattr(update_module, "BundleRegistry", lambda **kwargs: registry) monkeypatch.setattr( update_module, "AppSettings", @@ -464,9 +464,9 @@ async def test_global_update_keeps_an_app_subdirectory_on_a_different_ref(monkey monkeypatch.setattr( update_module, "AppBundleDiscovery", - lambda: SimpleNamespace(list_cached_root_bundles=lambda: ["team"]), + lambda **kwargs: SimpleNamespace(list_cached_root_bundles=lambda: ["team"]), ) - monkeypatch.setattr(update_module, "create_bundle_registry", lambda: registry) + monkeypatch.setattr(update_module, "BundleRegistry", lambda **kwargs: registry) monkeypatch.setattr( update_module, "AppSettings", diff --git a/tests/test_declared_module_updates.py b/tests/test_declared_module_updates.py index 5249e428..d9d2b85d 100644 --- a/tests/test_declared_module_updates.py +++ b/tests/test_declared_module_updates.py @@ -22,10 +22,9 @@ from amplifier_app_cli.commands import update as updater from amplifier_app_cli.lib.bundle_loader import discovery as discovery_module from amplifier_app_cli.lib.settings import AppSettings -from amplifier_app_cli.paths import create_bundle_registry from amplifier_app_cli.utils.file_uri import file_uri_path_value from amplifier_app_cli.utils.include_graph import ( - _local_path_for, + _cached_source_for, collect_declared_module_sources, ) from amplifier_app_cli.utils.source_status import CachedGitStatus, UpdateReport @@ -108,11 +107,11 @@ def write_bundle(uri, config): settings.get_provider_overrides.return_value = [] settings.get_added_bundles.return_value = {} monkeypatch.setattr(updater, "AppSettings", lambda: settings) - monkeypatch.setattr(updater, "create_bundle_registry", lambda: registry) + monkeypatch.setattr(updater, "BundleRegistry", lambda **kwargs: registry) monkeypatch.setattr( updater, "AppBundleDiscovery", - lambda: SimpleNamespace(list_cached_root_bundles=lambda: ["root"]), + lambda **kwargs: SimpleNamespace(list_cached_root_bundles=lambda: ["root"]), ) monkeypatch.setattr( updater, @@ -578,7 +577,7 @@ def test_real_discovery_walks_all_same_repo_root_manifests_before_row_dedup( monkeypatch.setattr( updater, "AppBundleDiscovery", discovery_module.AppBundleDiscovery ) - monkeypatch.setattr(updater, "create_bundle_registry", create_bundle_registry) + monkeypatch.setattr(updater, "BundleRegistry", BundleRegistry) discovery = discovery_module.AppBundleDiscovery() assert discovery.list_cached_root_bundles() == ["root"] @@ -677,7 +676,7 @@ def test_cached_bundle_include_file_uri_round_trip_decodes_once(scenario, tmp_pa encoding="utf-8", ) uri = local.as_uri() - assert _local_path_for(uri, scenario.cache) == local + assert _cached_source_for(uri, scenario.cache, scenario.registry).active_path == local scenario.write_bundle(CHILD, {"includes": [{"bundle": uri}]}) found, failures = asyncio.run( @@ -697,7 +696,375 @@ def test_ordinary_manifest_path_preserves_literal_percent_escape(tmp_path): local = tmp_path / "manifest space %20.yaml" local.write_text("bundle: {name: literal-path}\n", encoding="utf-8") - assert _local_path_for(str(local), tmp_path / "cache") == local + registry = BundleRegistry(home=tmp_path, persist=False) + assert _cached_source_for(str(local), tmp_path / "cache", registry).active_path == local + + +def test_relative_embedded_module_is_covered_by_exact_owner(scenario): + embedded = scenario.active_path(ROOT) / "modules" / "tool-embedded" + embedded.mkdir(parents=True) + scenario.write_bundle( + APP, {"tools": [{"module": "tool-embedded", "source": "../modules/tool-embedded"}]} + ) + found, failures = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert not failures + assert found[str(embedded)].declaring_uri == APP + assert found[str(embedded)].embedded_in == ROOT + statuses = asyncio.run(updater._check_all_bundle_status()) + assert str(embedded) not in statuses + scenario.behind.clear() + output = scenario.invoke("--check-only") + assert "tool-embedded" not in output + assert str(scenario.cache) not in output + assert "All sources up to date" in output + + +@pytest.mark.parametrize("as_file_uri", [False, True]) +def test_explicit_local_override_inside_cache_stays_visible(scenario, as_file_uri): + local = scenario.active_path(ROOT) / "modules" / "literal %20" + local.mkdir(parents=True) + uri = local.as_uri() if as_file_uri else str(local) + scenario.write_bundle(APP, {"tools": [{"module": "tool-worker", "source": uri}]}) + scenario.settings.get_source_overrides.return_value = {"tool-worker": uri} + found, failures = asyncio.run( + collect_declared_module_sources( + {"app": APP}, + registry=scenario.registry, + cache_dir=scenario.cache, + overrides={"tool-worker": uri}, + ) + ) + assert not failures and found[uri].embedded_in is None + scenario.behind.clear() + output = scenario.invoke("--check-only") + assert "tool-worker (local)" in output and "Local" in output + assert str(scenario.cache) not in output + assert "Not checked" not in output + scenario.invoke("--yes") + assert not scenario.refreshed + + +@pytest.mark.parametrize("kind", ["missing", "sibling", "escape", "symlink"]) +def test_unproven_local_ownership_is_not_hidden(scenario, kind): + root = scenario.active_path(ROOT) + sibling = scenario.active_path(CHILD) / "module" + sibling.mkdir() + if kind == "missing": + source = str(root / "missing") + elif kind == "sibling": + source = str(sibling) + elif kind == "escape": + source = str(root / ".." / sibling.parent.name / "module") + else: + (root / "linked").symlink_to(sibling, target_is_directory=True) + source = str(root / "linked") + scenario.write_bundle(APP, {"tools": [{"module": "tool-local", "source": source}]}) + found, _ = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert found[source].embedded_in is None + output = scenario.invoke("--check-only") + assert "tool-local (local)" in output + assert ("Missing path" if kind == "missing" else "Local") in output + + +def test_disagreeing_declarations_do_not_hide_shared_local_source(scenario): + local = scenario.active_path(ROOT) / "module" + local.mkdir() + declaration = {"tools": [{"module": "tool-shared", "source": str(local)}]} + scenario.write_bundle(APP, declaration) + scenario.write_bundle(CHILD, declaration) + found, _ = asyncio.run( + collect_declared_module_sources( + {"app": APP, "other": CHILD}, + registry=scenario.registry, + cache_dir=scenario.cache, + ) + ) + assert found[str(local)].embedded_in is None + + +@pytest.mark.parametrize("owner", [ROOT.replace("@main", "@next"), ROOT.replace(".git@", "@")]) +def test_embedded_owner_missing_after_report_dedup_gets_exact_cache_row(scenario, owner): + manifest = owner + "#subdirectory=behaviors/extra.yaml" + local = scenario.active_path(owner) / "module" + local.mkdir(parents=True) + scenario.write_bundle( + manifest, {"tools": [{"module": "tool-embedded", "source": "../module"}]} + ) + scenario.registry.register({"different-cache": manifest}) + scenario.registry.save() + # Reproduce repository row dedup omitting this root. Seeds still include it. + scenario.behind.clear() + scenario.behind.add(owner) + statuses = asyncio.run(updater._check_all_bundle_status()) + assert str(local) not in statuses + assert statuses[owner].bundle_source == owner + assert isinstance(statuses[owner], updater.TransitiveBundleStatus) + scenario.invoke("--yes") + assert scenario.refreshed == [owner] + scenario.installs.assert_not_called() + + +@pytest.mark.parametrize("namespace", ["shared-metadata", "modes"]) +def test_cached_extensionless_source_bound_includes(scenario, namespace): + # Containing root supplies only bindings: its own includes are not traversed. + scenario.write_bundle(ROOT, {"includes": ["not-registered"]}) + if namespace == "modes": + root_manifest = scenario.active_path(ROOT) / "bundle.md" + root_manifest.write_text("---\nbundle: {name: modes}\n---\n") + scenario.write_bundle( + APP, + {"bundle": {"name": "shared-metadata", "namespace_root": ".."}, + "includes": [f"{namespace}:behaviors/extra"]}, + ) + extra = ROOT + "#subdirectory=behaviors/extra.yaml" + scenario.write_bundle( + extra, + {"tools": [{"module": "tool-extra", "source": TOOL}], + "includes": [APP]}, + ) + # A conflicting registration must not replace this manifest's selected ref. + scenario.registry.register({namespace: CHILD}) + assert scenario.registry.get_state(namespace).local_path is None + found, failures = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert not failures + assert set(found) == {TOOL} + assert found[TOOL].declaring_uri == extra + scenario.forbidden.assert_not_called() + + +@pytest.mark.parametrize("verbose", [False, True]) +def test_incomplete_diagnostic_never_becomes_blank_module_row(scenario, verbose): + scenario.behind.clear() + scenario.active_path(APP).unlink() + statuses = asyncio.run(updater._check_all_bundle_status()) + assert updater._collect_unified_modules(scenario.report, statuses) == {} + output = scenario.invoke("--check-only", *(["--verbose"] if verbose else [])) + assert "incomplete module discovery" in output + assert "could not be checked" in output + assert "Modules" not in output + + +@pytest.mark.parametrize("force", [False, True]) +def test_real_check_registry_does_not_persist_stale_path_cleanup(scenario, monkeypatch, force): + scenario.registry.get_state("root").local_path = str(scenario.cache / "gone") + scenario.registry.save() + home = scenario.cache.parent + before = { + p.relative_to(home): p.read_bytes() if p.is_file() else None + for p in home.rglob("*") + } + monkeypatch.setattr(discovery_module, "WELL_KNOWN_BUNDLES", {}) + monkeypatch.setattr( + "amplifier_app_cli.lib.settings.AppSettings", lambda: scenario.settings + ) + monkeypatch.setattr(updater, "AppBundleDiscovery", discovery_module.AppBundleDiscovery) + monkeypatch.setattr(updater, "BundleRegistry", BundleRegistry) + scenario.invoke("--check-only", *(["--force"] if force else [])) + assert { + p.relative_to(home): p.read_bytes() if p.is_file() else None + for p in home.rglob("*") + } == before + assert not scenario.refreshed + for boundary in ( + scenario.apply, scenario.installs, scenario.execute, scenario.clear, + scenario.skills, scenario.save, scenario.forbidden, + ): + boundary.assert_not_called() + + +@pytest.mark.parametrize("malformed", [False, True]) +def test_omitted_owner_discovery_failure_cannot_be_green(scenario, malformed): + owner = ROOT.replace("@main", "@next") + manifest = owner + "#subdirectory=behaviors/extra.yaml" + path = scenario.write_bundle(manifest, {}) + if malformed: + path.write_text("bundle: [broken\n") + else: + path.unlink() + scenario.registry.register({"omitted": manifest}) + scenario.behind.clear() + output = scenario.invoke("--check-only") + assert "incomplete module discovery" in output + assert "All sources up to date" not in output + assert owner in scenario.checked + + +def test_recovered_owner_status_failure_keeps_independent_module_action(scenario): + owner = ROOT.replace("@main", "@next") + manifest = owner + "#subdirectory=behaviors/extra.yaml" + (scenario.active_path(owner) / "module").mkdir(parents=True) + scenario.write_bundle( + manifest, {"tools": [{"module": "tool-embedded", "source": "../module"}]} + ) + scenario.registry.register({"omitted": manifest}) + scenario.errors[owner] = TimeoutError("private-token") + output = scenario.invoke("--check-only") + assert "timeout" in output and "private-token" not in output + assert "Update 2 modules" in output + + +@pytest.mark.parametrize("link", ["manifest", "cache"]) +def test_escaped_declaring_manifest_is_incomplete_not_embedded(scenario, link): + root = scenario.active_path(ROOT) + outside = scenario.active_path(CHILD) + (root / "module").mkdir() + scenario.write_bundle( + CHILD, {"tools": [{"module": "tool-embedded", "source": str(root / "module")}]} + ) + if link == "manifest": + (root / "bundle.md").unlink() + (root / "bundle.md").symlink_to(outside / "bundle.md") + else: + # Only delete this fixture's own empty files/directories. + (root / "bundle.md").unlink() + scenario.active_path(APP).unlink() + (root / "behaviors").rmdir() + (root / "module").rmdir() + root.rmdir() + root.symlink_to(outside, target_is_directory=True) + found, failures = asyncio.run( + collect_declared_module_sources( + {"root": ROOT}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert not found + assert failures == {"root": "Incomplete cached module discovery"} + + +@pytest.mark.parametrize("stale_path", [False, True]) +def test_foreign_cached_namespace_uses_selected_uri_and_namespace_root(scenario, stale_path): + selected = CHILD.replace("@main", "@next") + "#subdirectory=bundles/foreign/bundle.yaml" + scenario.write_bundle( + selected, {"bundle": {"name": "foreign", "namespace_root": "../.."}, + "includes": ["not-traversed"]} + ) + extra = CHILD.replace("@main", "@next") + "#subdirectory=behaviors/extra.yaml" + scenario.write_bundle(extra, {"tools": [{"module": "tool-extra", "source": TOOL}]}) + scenario.registry.register({"foreign": selected}) + if stale_path: + scenario.registry.get_state("foreign").local_path = str(scenario.active_path(ROOT)) + scenario.registry.save() + before = (scenario.cache.parent / "registry.json").read_bytes() + scenario.write_bundle(APP, {"includes": ["foreign:behaviors/extra"]}) + found, failures = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert not failures and set(found) == {TOOL} + assert found[TOOL].declaring_uri == extra + assert (scenario.cache.parent / "registry.json").read_bytes() == before + + +@pytest.mark.parametrize("override_first", [False, True]) +def test_shared_local_source_with_one_explicit_override_never_hidden(scenario, override_first): + local = scenario.active_path(ROOT) / "module" + local.mkdir() + rows = [ + {"module": "tool-embedded", "source": str(local)}, + {"module": "tool-override", "source": str(local)}, + ] + scenario.write_bundle(APP, {"tools": rows[::-1] if override_first else rows}) + found, _ = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache, + overrides={"tool-override": str(local)}, + ) + ) + assert found[str(local)].embedded_in is None + + +@pytest.mark.parametrize("malformed", [False, True]) +def test_foreign_namespace_requires_its_selected_manifest(scenario, malformed): + selected = CHILD + "#subdirectory=bundles/foreign/bundle.yaml" + path = scenario.write_bundle(selected, {"bundle": {"name": "foreign"}}) + if malformed: + path.write_text("bundle: [broken\n") + else: + path.unlink() + # The resource exists at the guessed fallback path, but missing namespace + # metadata cannot establish that this is the selected namespace's resource. + scenario.write_bundle( + CHILD + "#subdirectory=behaviors/extra.yaml", + {"tools": [{"module": "tool-extra", "source": TOOL}]}, + ) + scenario.registry.register({"foreign": selected}) + scenario.write_bundle(APP, {"includes": ["foreign:behaviors/extra"]}) + found, failures = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert not found + assert failures == {"app": "Incomplete cached module discovery"} + + +def test_explicit_extensionless_git_include_keeps_uri_identity(scenario): + extensionless = CHILD + "#subdirectory=behaviors/extra" + scenario.write_bundle( + extensionless + ".yaml", {"tools": [{"module": "tool-extra", "source": TOOL}]} + ) + scenario.write_bundle(APP, {"includes": [extensionless]}) + found, failures = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert not failures and found[TOOL].declaring_uri == extensionless + + +@pytest.mark.parametrize("embedded_first", [False, True]) +@pytest.mark.parametrize("diagnostic", [False, True]) +@pytest.mark.parametrize("subdirectory", [False, True]) +def test_exact_git_module_install_survives_embedded_owner_recovery( + scenario, embedded_first, diagnostic, subdirectory +): + owner = ROOT.replace("@main", "@next") + explicit = owner + "#subdirectory=installable" if subdirectory else owner + manifest = owner + "#subdirectory=behaviors/extra.yaml" + module = scenario.active_path(owner) / "embedded" + module.mkdir(parents=True) + installable = scenario.active_path(explicit) + installable.mkdir(exist_ok=True) + (installable / "pyproject.toml").write_text( + '[project]\nname = "fixture-root-module"\nversion = "1.0.0"\n' + ) + rows = [ + {"module": "tool-root-git", "source": explicit}, + {"module": "tool-embedded", "source": "../embedded"}, + ] + scenario.write_bundle( + manifest, + {"tools": rows[::-1] if embedded_first else rows, + "includes": ["missing:absent"] if diagnostic else []}, + ) + scenario.registry.register({"omitted": manifest}) + scenario.behind.clear() + scenario.behind.add(explicit) + statuses = asyncio.run(updater._check_all_bundle_status()) + assert isinstance(statuses[explicit], updater.DeclaredModuleStatus) + assert sum( + s.bundle_source.split("#")[0] == owner for s in statuses.values() + ) == 1 + output = scenario.invoke("--yes") + assert scenario.refreshed == [explicit] + assert scenario.apply.await_args.kwargs["selective"] == [explicit] + assert scenario.apply.await_args.kwargs["install_deps"] is True + scenario.installs.assert_awaited_once_with(installable) + if diagnostic: + assert "incomplete module discovery" in output @pytest.mark.parametrize( diff --git a/tests/test_transitive_include_updates.py b/tests/test_transitive_include_updates.py index 91a97544..446340b7 100644 --- a/tests/test_transitive_include_updates.py +++ b/tests/test_transitive_include_updates.py @@ -119,6 +119,185 @@ def _direct_status(name: str, uri: str): return BundleStatus(bundle_name=name, bundle_source=uri, sources=[]) +def _write_installable_fixture(path: Path, name: str, value: str) -> None: + """A network-free PEP 660 backend; installs only into this test's venv.""" + path.mkdir(parents=True, exist_ok=True) + (path / f"{name}.py").write_text(f"VALUE = {value!r}\n", encoding="utf-8") + (path / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "1.0.0"\n' + '[build-system]\nrequires = []\nbuild-backend = "fixture_backend"\n' + 'backend-path = ["."]\n', + encoding="utf-8", + ) + (path / "fixture_backend.py").write_text( + "from pathlib import Path\n" + "from zipfile import ZipFile\n" + f"NAME = {name!r}\n" + "def build_editable(wheel_directory, config_settings=None, metadata_directory=None):\n" + " wheel = f'{NAME}-1.0.0-py3-none-any.whl'\n" + " dist = f'{NAME}-1.0.0.dist-info'\n" + " files = {\n" + " f'{NAME}.py': Path(__file__).with_name(f'{NAME}.py').read_text(),\n" + " f'{dist}/METADATA': f'Metadata-Version: 2.1\\n" + "Name: {NAME}\\nVersion: 1.0.0\\n',\n" + " f'{dist}/WHEEL': 'Wheel-Version: 1.0\\nGenerator: fixture\\n" + "Root-Is-Purelib: true\\nTag: py3-none-any\\n',\n" + " }\n" + " files[f'{dist}/RECORD'] = ''.join(f'{key},,\\n' for key in files)" + " + f'{dist}/RECORD,,\\n'\n" + " with ZipFile(Path(wheel_directory) / wheel, 'w') as archive:\n" + " for key, content in files.items():\n" + " archive.writestr(key, content)\n" + " return wheel\n" + "build_wheel = build_editable\n", + encoding="utf-8", + ) + + +@pytest.mark.parametrize("embedded", [False, True]) +def test_actual_update_advances_module_bytes_and_preserves_unrelated_cache( + tmp_path, amplifier_home, monkeypatch, embedded +): + """Real Click -> Git refresh -> isolated installation, no user's runtime.""" + import asyncio + import io + from unittest.mock import AsyncMock + + from amplifier_foundation import BundleRegistry + from amplifier_foundation.modules.activator import ModuleActivator + from amplifier_foundation.paths.resolution import parse_uri + from amplifier_foundation.sources.git import GitSourceHandler + from click.testing import CliRunner + from rich.console import Console + + from amplifier_app_cli.commands import update as updater + from amplifier_app_cli.lib.bundle_loader import discovery + from amplifier_app_cli.utils.source_status import UpdateReport + from amplifier_app_cli.utils.update_executor import ExecutionResult + + repo = tmp_path / "module-repo" + _make_repo(repo, _bundle_md("module-owner")) + for name in ("fixture_tool", "fixture_hook"): + _write_installable_fixture(repo / "modules" / name, name, "old") + _commit(repo, "add modules") + module_uri = _git_uri(repo, "main") + sources = [ + module_uri + f"#subdirectory=modules/{name}" + for name in ("fixture_tool", "fixture_hook") + ] + if embedded: + parent_uri = module_uri + source_values = ["./modules/fixture_tool", "./modules/fixture_hook"] + else: + parent = tmp_path / "parent-repo" + _make_repo(parent, _bundle_md("current-parent")) + parent_uri = _git_uri(parent, "main") + source_values = sources + parent_repo = repo if embedded else parent + (parent_repo / "bundle.md").write_text( + "---\nbundle: {name: fixture-parent}\n" + f"tools:\n - module: tool-fixture\n source: {source_values[0]}\n" + f"hooks:\n - module: hooks-fixture\n source: {source_values[1]}\n---\n", + encoding="utf-8", + ) + _commit(parent_repo, "declare modules") + cache = amplifier_home / "cache" + parent_cache = asyncio.run(_prime_cache(parent_uri, cache)) + module_cache = asyncio.run(_prime_cache(module_uri, cache)) + unrelated_repo = tmp_path / "unrelated" + _make_repo(unrelated_repo, _bundle_md("unrelated")) + unrelated = asyncio.run(_prime_cache(_git_uri(unrelated_repo, "main"), cache)) + unrelated_before = { + p.relative_to(unrelated): p.read_bytes() + for p in unrelated.rglob("*") if p.is_file() + } + parent_head = _cached_head(parent_cache) + for name in ("fixture_tool", "fixture_hook"): + _write_installable_fixture(repo / "modules" / name, name, "new") + new_head = _commit(repo, "advance only module sources") + assert _cached_head(module_cache) != new_head + + registry = BundleRegistry(home=amplifier_home) + registry.register({"fixture-parent": parent_uri}) + registry.save() + monkeypatch.setattr(discovery, "WELL_KNOWN_BUNDLES", {}) + monkeypatch.setattr( + "amplifier_app_cli.utils.umbrella_discovery.discover_umbrella_source", lambda: None + ) + monkeypatch.setattr(updater, "check_all_sources", AsyncMock( + return_value=UpdateReport(local_file_sources=[], cached_git_sources=[]) + )) + monkeypatch.setattr(updater, "execute_updates", AsyncMock( + return_value=ExecutionResult(success=True, updated=[], messages=[]) + )) + monkeypatch.setattr(updater, "_refresh_skills_cache", lambda console: None) + monkeypatch.setattr(updater, "save_update_last_check", lambda value: None) + output = io.StringIO() + monkeypatch.setattr(updater, "console", Console(file=output, width=240)) + # update_bundle has no target-interpreter argument; override only its install + # destination, retaining its real dependency-install implementation. + venv = tmp_path / "install-venv" + subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(venv)], check=True) + real_init = ModuleActivator.__init__ + + def isolated_init(self, *args, **kwargs): + real_init(self, *args, install_python=str(venv / "bin" / "python"), **kwargs) + + monkeypatch.setattr(ModuleActivator, "__init__", isolated_init) + refreshed = [] + installed_paths = [] + real_update = GitSourceHandler.update + real_install = ModuleActivator._install_dependencies + + async def recording_update(self, parsed, cache_dir): + refreshed.append(parsed) + return await real_update(self, parsed, cache_dir) + + async def recording_install(self, path): + installed_paths.append(path) + return await real_install(self, path) + + monkeypatch.setattr(GitSourceHandler, "update", recording_update) + monkeypatch.setattr(ModuleActivator, "_install_dependencies", recording_install) + for flags in (["--check-only"], ["--check-only", "--force"]): + before = {p.relative_to(amplifier_home): p.read_bytes() if p.is_file() else None + for p in amplifier_home.rglob("*")} + checked = CliRunner().invoke(updater.update, flags) + assert checked.exit_code == 0, checked.exception + assert {p.relative_to(amplifier_home): p.read_bytes() if p.is_file() else None + for p in amplifier_home.rglob("*")} == before + assert not refreshed and not installed_paths + result = CliRunner().invoke(updater.update, ["--yes"]) + assert result.exit_code == 0, (result.exception, output.getvalue()) + assert "Update complete" in output.getvalue() + assert _cached_head(module_cache) == new_head + for name in ("fixture_tool", "fixture_hook"): + assert (module_cache / "modules" / name / f"{name}.py").read_text() == "VALUE = 'new'\n" + if embedded: + assert refreshed == [parse_uri(parent_uri)] + assert not installed_paths + assert "Updated bundle:" in output.getvalue() + assert "Updated module:" not in output.getvalue() + assert not list(venv.rglob("fixture_tool.py")) + else: + assert refreshed == [parse_uri(uri) for uri in sources] + assert installed_paths == [ + module_cache / "modules" / name + for name in ("fixture_tool", "fixture_hook") + ] + assert _cached_head(parent_cache) == parent_head + installed = subprocess.run( + [str(venv / "bin" / "python"), "-c", + "import fixture_tool, fixture_hook; print(fixture_tool.VALUE, fixture_hook.VALUE)"], + check=True, capture_output=True, text=True, + ) + assert installed.stdout.strip() == "new new" + assert { + p.relative_to(unrelated): p.read_bytes() + for p in unrelated.rglob("*") if p.is_file() + } == unrelated_before + + # --------------------------------------------------------------------------- # The core defect: an @main include that only a walk can find # --------------------------------------------------------------------------- diff --git a/tests/test_update_reporting.py b/tests/test_update_reporting.py index f58d9d6e..a9e9db80 100644 --- a/tests/test_update_reporting.py +++ b/tests/test_update_reporting.py @@ -276,6 +276,53 @@ async def fake_execute_updates(*args, **kwargs): ) +@pytest.mark.parametrize("verbose", [False, True]) +def test_local_module_labels_are_safe_and_visibly_unique(tmp_path, monkeypatch, verbose): + results = {} + # Letter-leading hash suffixes look like Rich tags unless labels are escaped. + for index in range(100): + path = tmp_path / f"private-source-{index}" + key = str(path) + if update_module._source_hash_suffixes([key])[key][0] not in "abcdef": + continue + path.mkdir() + results[key] = update_module.DeclaredModuleStatus( + bundle_name="tool-shared", bundle_source=key, module_id="tool-shared", + sources=[_source(key, is_cached=True, has_update=None)], + ) + if len(results) == 2: + break + assert len(results) == 2 + labels = update_module._result_display_names(results) + output = io.StringIO() + monkeypatch.setattr(update_module, "console", Console(file=output, width=240)) + report = UpdateReport(local_file_sources=[], cached_git_sources=[]) + with ExitStack() as stack: + for boundary in _command_patches(report, results): + stack.enter_context(boundary) + result = CliRunner().invoke(update, ["--check-only", *(["--verbose"] if verbose else [])]) + assert result.exit_code == 0, result.exception + assert len(set(labels.values())) == 2 + for label in labels.values(): + assert label in output.getvalue() + assert str(tmp_path) not in output.getvalue() + assert "private-source" not in output.getvalue() + assert "Local" in output.getvalue() + + +def test_native_path_classification_preserves_literals_and_does_not_claim_aliases(tmp_path): + local = tmp_path / "literal %20" + local.mkdir() + for uri in (str(local), local.as_uri()): + assert _classify_source_status(_source(uri, is_cached=True, has_update=None)) == "local" + assert _classify_source_status( + _source(str(local / "missing"), is_cached=False, has_update=None) + ) == "missing_path" + assert _classify_source_status( + _source("registered/name", is_cached=True, has_update=None) + ) == "not_checked" + + def test_missing_bundle_caches_are_downloads_not_updates(monkeypatch): """A cache reset keeps lazy downloads, but calls the first fetch a download.""" From f87d0c0b7462c05eb5e2b6b0eca6b6ca9da84541 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:59:12 -0700 Subject: [PATCH 2/2] fix: close file-fragment ownership and refresh duplicate cache owners Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_app_cli/commands/update.py | 13 +- amplifier_app_cli/utils/include_graph.py | 16 +- tests/test_declared_module_updates.py | 177 ++++++++++++++++++++++- 3 files changed, 194 insertions(+), 12 deletions(-) diff --git a/amplifier_app_cli/commands/update.py b/amplifier_app_cli/commands/update.py index 7a00027b..777f1e49 100644 --- a/amplifier_app_cli/commands/update.py +++ b/amplifier_app_cli/commands/update.py @@ -2162,7 +2162,7 @@ def _refresh_skills_cache(console: Console) -> None: def _deduplicate_declared_module_caches( report, bundle_results: dict[str, _BundleStatus] ) -> None: - """Let exact declared module actions own their legacy repository refresh. + """Let declared module and recovered owner actions own legacy refreshes. Cache identity comes from Foundation's URL + ref key, never the displayed module name. Legacy root-only entries cannot describe subdirectory installs. @@ -2187,12 +2187,13 @@ def cache_key(uri: str) -> Path | None: return None for status in bundle_results.values(): - if not isinstance(status, DeclaredModuleStatus): + if not isinstance(status, TransitiveBundleStatus): continue - for original in status.overridden_sources: - key = cache_key(original) - if key is not None: - overridden.setdefault(key, set()).add(original) + if isinstance(status, DeclaredModuleStatus): + for original in status.overridden_sources: + key = cache_key(original) + if key is not None: + overridden.setdefault(key, set()).add(original) uri = status.bundle_source if not uri: continue diff --git a/amplifier_app_cli/utils/include_graph.py b/amplifier_app_cli/utils/include_graph.py index e16877aa..2203e331 100644 --- a/amplifier_app_cli/utils/include_graph.py +++ b/amplifier_app_cli/utils/include_graph.py @@ -125,10 +125,17 @@ def has_update(self) -> bool: def local_source_path(uri: str) -> Path | None: - """Decode explicit file URIs once; don't mistake registry aliases for paths.""" + """Return the effective local path, decoding only explicit file URI bases once.""" if uri.lower().startswith("file:"): + from amplifier_foundation.paths.resolution import parse_uri + native = file_uri_path_value(uri, windows=os.name == "nt") - return Path(native) if native is not None else None + if native is None: + return None + # Normalize the prefix for Foundation's fragment parser, not the filename. + subpath = parse_uri("file://" + uri.partition(":")[2]).subpath + path = Path(native) + return path / subpath if subpath else path if Path(uri).is_absolute() or uri.startswith(("./", "../", ".\\", "..\\")): return Path(uri) return None @@ -152,7 +159,10 @@ def _cached_source_for( path = source_root / parsed.subpath if parsed.subpath else source_root path = registry._find_resource_path(path) else: - path = local_source_path(uri) + # Keep the base separate: local_source_path otherwise includes the fragment. + path = local_source_path( + strip_uri_fragment(uri) if uri.lower().startswith("file:") else uri + ) if path is None: return None base = path.resolve() diff --git a/tests/test_declared_module_updates.py b/tests/test_declared_module_updates.py index d9d2b85d..bd749d58 100644 --- a/tests/test_declared_module_updates.py +++ b/tests/test_declared_module_updates.py @@ -22,10 +22,12 @@ from amplifier_app_cli.commands import update as updater from amplifier_app_cli.lib.bundle_loader import discovery as discovery_module from amplifier_app_cli.lib.settings import AppSettings +from amplifier_app_cli.utils import module_cache, source_status, update_executor from amplifier_app_cli.utils.file_uri import file_uri_path_value from amplifier_app_cli.utils.include_graph import ( _cached_source_for, collect_declared_module_sources, + local_source_path, ) from amplifier_app_cli.utils.source_status import CachedGitStatus, UpdateReport from amplifier_app_cli.utils.update_executor import ExecutionResult @@ -663,7 +665,10 @@ async def check_status(self, parsed, cache_dir): scenario.apply.assert_not_called() -def test_cached_bundle_include_file_uri_round_trip_decodes_once(scenario, tmp_path): +@pytest.mark.parametrize("fragment", [False, True]) +def test_cached_bundle_include_file_uri_round_trip_decodes_once( + scenario, tmp_path, fragment +): local = tmp_path / "manifest space %20.yaml" extra = MODULES + "#subdirectory=extra" local.write_text( @@ -675,7 +680,10 @@ def test_cached_bundle_include_file_uri_round_trip_decodes_once(scenario, tmp_pa ), encoding="utf-8", ) - uri = local.as_uri() + uri = ( + tmp_path.as_uri() + f"#subdirectory={local.name}" + if fragment else local.as_uri() + ) assert _cached_source_for(uri, scenario.cache, scenario.registry).active_path == local scenario.write_bundle(CHILD, {"includes": [{"bundle": uri}]}) @@ -723,6 +731,35 @@ def test_relative_embedded_module_is_covered_by_exact_owner(scenario): assert "All sources up to date" in output +@pytest.mark.parametrize("source_form", ["native", "file", "file_fragment"]) +def test_local_module_effective_path_preserves_literal_percent_and_hash( + scenario, source_form +): + root = scenario.active_path(ROOT) + local = root / "module space %20#literal" + local.mkdir() + source = { + "native": str(local), + "file": local.as_uri(), + "file_fragment": root.as_uri() + f"#subdirectory={local.name}", + }[source_form] + assert local_source_path(source) == local + assert updater._classify_source_status( + SourceStatus(source_uri=source, is_cached=True, has_update=None) + ) == "local" + scenario.write_bundle( + APP, {"tools": [{"module": "tool-embedded", "source": source}]} + ) + found, failures = asyncio.run( + collect_declared_module_sources( + {"app": APP}, registry=scenario.registry, cache_dir=scenario.cache + ) + ) + assert not failures + assert found[source].embedded_in == ROOT + assert source not in asyncio.run(updater._check_all_bundle_status()) + + @pytest.mark.parametrize("as_file_uri", [False, True]) def test_explicit_local_override_inside_cache_stays_visible(scenario, as_file_uri): local = scenario.active_path(ROOT) / "modules" / "literal %20" @@ -749,7 +786,8 @@ def test_explicit_local_override_inside_cache_stays_visible(scenario, as_file_ur @pytest.mark.parametrize("kind", ["missing", "sibling", "escape", "symlink"]) -def test_unproven_local_ownership_is_not_hidden(scenario, kind): +@pytest.mark.parametrize("file_fragment", [False, True]) +def test_unproven_local_ownership_is_not_hidden(scenario, kind, file_fragment): root = scenario.active_path(ROOT) sibling = scenario.active_path(CHILD) / "module" sibling.mkdir() @@ -762,6 +800,14 @@ def test_unproven_local_ownership_is_not_hidden(scenario, kind): else: (root / "linked").symlink_to(sibling, target_is_directory=True) source = str(root / "linked") + expected_path = local_source_path(source) + assert expected_path is not None + if file_fragment: + subpath = ( + str(sibling) if kind == "sibling" else str(expected_path.relative_to(root)) + ) + source = root.as_uri() + f"#subdirectory={subpath}" + assert local_source_path(source) == expected_path scenario.write_bundle(APP, {"tools": [{"module": "tool-local", "source": source}]}) found, _ = asyncio.run( collect_declared_module_sources( @@ -769,6 +815,11 @@ def test_unproven_local_ownership_is_not_hidden(scenario, kind): ) ) assert found[source].embedded_in is None + statuses = asyncio.run(updater._check_all_bundle_status()) + assert statuses[source].bundle_source == source + assert updater._classify_source_status(statuses[source].sources[0]) == ( + "missing_path" if kind == "missing" else "local" + ) output = scenario.invoke("--check-only") assert "tool-local (local)" in output assert ("Missing path" if kind == "missing" else "Local") in output @@ -1067,6 +1118,126 @@ def test_exact_git_module_install_survives_embedded_owner_recovery( assert "incomplete module discovery" in output +@pytest.mark.parametrize("install_source", ["none", "root", "subdirectory"]) +def test_hybrid_owner_coalesces_real_legacy_refresh_without_losing_module_install( + scenario, monkeypatch, install_source +): + owner = ROOT.replace("@main", "@next") + manifest = owner + "#subdirectory=behaviors/hybrid.yaml" + controls = [ + owner.replace("@next", "@other"), + owner.replace(".git@", "@"), + owner.replace("/team/", "/other/"), + ] + + def seed_module_cache(uri, module_id): + path = scenario.active_path(uri) + path.mkdir(parents=True, exist_ok=True) + (path / "pyproject.toml").write_text( + f'[project]\nname = "{module_id}"\nversion = "1.0.0"\n' + '[project.entry-points."amplifier.modules"]\n' + f'"{module_id}" = "fixture:mount"\n', + encoding="utf-8", + ) + parsed = parse_uri(uri) + (path / ".amplifier_cache_meta.json").write_text( + json.dumps({ + "git_url": f"https://{parsed.host}{parsed.path}", + "ref": parsed.ref, + "commit": "a" * 40, + }), + encoding="utf-8", + ) + return path + + owner_path = seed_module_cache(owner, "tool-hybrid") + for index, uri in enumerate(controls): + seed_module_cache(uri, f"tool-control-{index}") + (owner_path / "embedded").mkdir() + rows = [{"module": "tool-embedded", "source": "../embedded"}] + explicit = owner + if install_source != "none": + if install_source == "subdirectory": + explicit += "#subdirectory=installable" + installable = scenario.active_path(explicit) + installable.mkdir() + (installable / "pyproject.toml").write_text( + '[project]\nname = "fixture-installable"\nversion = "1.0.0"\n', + encoding="utf-8", + ) + rows.append({"module": "tool-independent", "source": explicit}) + scenario.write_bundle(ROOT, {}) + scenario.write_bundle(manifest, {"tools": rows}) + assert not (owner_path / "bundle.md").exists() + assert not (owner_path / "bundle.yaml").exists() + scenario.registry.register({"hybrid": manifest}) + scenario.registry.save() + scenario.settings.get_app_bundles.return_value = [] + scenario.behind.clear() + scenario.behind.update({owner, explicit}) + + # Use real discovery, legacy scanner, Click, executor and cache refresher. + # Only settings, remote status/update and dependency installation are faked. + monkeypatch.setattr(discovery_module, "WELL_KNOWN_BUNDLES", {}) + monkeypatch.setattr( + "amplifier_app_cli.lib.settings.AppSettings", lambda: scenario.settings + ) + monkeypatch.setattr(updater, "AppBundleDiscovery", discovery_module.AppBundleDiscovery) + monkeypatch.setattr(updater, "BundleRegistry", BundleRegistry) + monkeypatch.setattr( + source_status, "_get_github_commit_sha", AsyncMock(return_value="b" * 40) + ) + check = AsyncMock(wraps=source_status.check_all_sources) + execute = AsyncMock(wraps=update_executor.execute_updates) + refresh = AsyncMock(wraps=module_cache.update_module) + monkeypatch.setattr(updater, "check_all_sources", check) + monkeypatch.setattr(updater, "execute_updates", execute) + monkeypatch.setattr(module_cache, "update_module", refresh) + + assert discovery_module.AppBundleDiscovery().list_cached_root_bundles() == ["root"] + cached = module_cache.scan_cached_modules() + assert len(cached) == 4 + assert next(m for m in cached if m.cache_path == owner_path).module_type == "tool" + statuses = asyncio.run(updater._check_all_bundle_status()) + selected = explicit if install_source != "none" else owner + if install_source == "none": + assert isinstance(statuses[owner], updater.TransitiveBundleStatus) + assert not isinstance(statuses[owner], updater.DeclaredModuleStatus) + else: + assert isinstance(statuses[explicit], updater.DeclaredModuleStatus) + assert str(owner_path / "embedded") not in statuses + + scenario.invoke("--check-only") + assert not scenario.refreshed + execute.assert_not_called() + refresh.assert_not_called() + output = scenario.invoke("--yes") + assert "Update complete" in output + assert len(scenario.refreshed) == 4 + assert scenario.refreshed.count(selected) == 1 + assert set(scenario.refreshed) == {*controls, selected} + execute.assert_awaited_once() + assert { + (s.url, s.ref) for s in execute.await_args.args[0].cached_git_sources + } == { + (f"https://{parse_uri(uri).host}{parse_uri(uri).path}", parse_uri(uri).ref) + for uri in controls + } + assert refresh.await_count == 3 # Only the distinct legacy cache identities. + if install_source == "none": + scenario.apply.assert_not_called() + scenario.installs.assert_not_called() + else: + scenario.apply.assert_awaited_once() + assert scenario.apply.await_args.kwargs == { + "cache_dir": scenario.cache, + "selective": [explicit], + "install_deps": True, + } + scenario.installs.assert_awaited_once_with(scenario.active_path(explicit)) + scenario.forbidden.assert_not_called() + + @pytest.mark.parametrize( ("uri", "expected"), [