From 2eee767f4053766361818f4a230b0a3503fdf24a Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 23 Oct 2026 06:17:42 +0200 Subject: [PATCH 01/27] feat(bundles): allow bundles to reference other bundles Add `provides.bundles` so a bundle.yml can reference an exact version of another bundle. References are expanded into the parent's component plan before installation, using existing catalog precedence, install policy, conflict checks and primitive installers. Validation, `bundle info`, install, update and refresh resolve the same expanded plan. Closes #5047 Assisted-by: pi (model: claude-sonnet-5.5, autonomous) --- docs/reference/bundles.md | 87 ++- src/specify_cli/bundles/_commands.py | 36 +- src/specify_cli/bundles/catalog_stack.py | 32 +- src/specify_cli/bundles/command_info.py | 123 +++- src/specify_cli/bundles/command_install.py | 23 +- src/specify_cli/bundles/command_update.py | 15 +- src/specify_cli/bundles/command_validate.py | 82 ++- src/specify_cli/bundles/conflict.py | 23 +- src/specify_cli/bundles/expansion.py | 548 +++++++++++++++ src/specify_cli/bundles/installer.py | 24 +- src/specify_cli/bundles/manifest.py | 75 ++- src/specify_cli/bundles/records.py | 62 +- src/specify_cli/bundles/resolver.py | 78 ++- src/specify_cli/bundles/sources.py | 64 ++ src/specify_cli/bundles/validator.py | 4 +- tests/specify_cli/bundles/helpers.py | 38 +- .../specify_cli/bundles/test_catalog_stack.py | 18 +- .../specify_cli/bundles/test_command_build.py | 37 ++ .../specify_cli/bundles/test_command_info.py | 302 ++++++++- .../bundles/test_command_install.py | 311 +++++++++ .../specify_cli/bundles/test_command_list.py | 23 + .../bundles/test_command_update.py | 283 ++++++++ .../bundles/test_command_validate.py | 212 ++++++ tests/specify_cli/bundles/test_conflict.py | 116 +++- tests/specify_cli/bundles/test_expansion.py | 628 ++++++++++++++++++ tests/specify_cli/bundles/test_installer.py | 150 ++++- tests/specify_cli/bundles/test_manifest.py | 221 ++++++ tests/specify_cli/bundles/test_packager.py | 36 + tests/specify_cli/bundles/test_records.py | 91 ++- tests/specify_cli/bundles/test_resolver.py | 230 ++++++- tests/specify_cli/bundles/test_sources.py | 407 +++++++++++- tests/specify_cli/bundles/test_validator.py | 36 +- 32 files changed, 4344 insertions(+), 71 deletions(-) create mode 100644 src/specify_cli/bundles/expansion.py create mode 100644 tests/specify_cli/bundles/test_expansion.py create mode 100644 tests/specify_cli/bundles/test_manifest.py diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 0e060efc2a..44157ed5da 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -55,7 +55,7 @@ specify bundle info --versions | `--json` | Emit machine-readable JSON | | `--versions` | List the current and historical catalog releases | -Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. +Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, and `via`; referenced component entries add `origin` and, when applicable, `overrides`. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. Use `specify bundle info --versions` to inspect the catalog history without downloading a bundle manifest. With `--json`, it emits the bundle ID, available versions, current version, source, and install policy. @@ -105,6 +105,87 @@ store actual known installed versions under `installed_versions`, keyed by `specify bundle list --json`. Component version ranges, `latest`, and omitted extension versions are not supported. +## Bundle References + +A bundle can compose other bundles with exact, pinned references in +`provides.bundles`. A non-empty reference list requires manifest schema `1.1`; +empty or null lists are treated as no references. Each reference has a bundle +ID, an exact SemVer `version`, and an optional `source` naming the expected +winning bundle catalog (not a URL). Reference IDs are safe slugs, must be +unique within the manifest, and cannot name the parent bundle: + +```yaml +schema_version: "1.1" +bundle: + id: team + name: Team setup + version: 1.0.0 + role: developer + description: A team bundle + author: Example team + license: MIT +requires: + speckit_version: ">=0.1.0" +provides: + bundles: + - id: bugfix + version: 1.0.1 + source: default + - id: qa-pack + version: 2.0.0 +``` + +References resolve at command time through the effective bundle catalog stack. +The stack's winning entry is selected for each exact pin; an explicit `source` +is an assertion that this is the winning catalog, not a request to change +precedence; a mismatch is an error and does not fall through to another +catalog. A referenced bundle from a discovery-only source can be inspected +by `info`, but `install`, `update`, and online `validate` reject it as +non-installable. Every bundle ID may have only one version in an expansion; +cycles and a second version for the same ID are errors. + +The expansion is one flat component plan. Referenced bundles are walked +recursively in declaration order (depth-first), followed by the declaring +bundle's own components, while preserving the existing extension, preset, step, +and workflow grouping. A component is installed at most once per `kind:id`. +Copies agree when their version and `source` match (omitted and set `source` +values differ); preset copies must also have the same `priority` and `strategy`. +Other differences between sibling references are conflicts that must be fixed +in the parent. At every level, a bundle's own declaration wins over its +references, takes the declaration's position, and emits a warning describing +the override and its origin chain. + +Expansion allows at most 10 levels of reference nesting and 50 distinct +referenced bundle IDs, excluding the root. A cycle is rejected with its +reference chain. At install and update time, the Spec Kit version requirement +is checked for every bundle in the graph, and an incompatible requirement is +a hard error, while the root bundle remains the only integration gate: a +referenced bundle's differing or unknown integration is a warning. +`requires.tools` and `requires.mcp` are unioned across the graph +and rendered through the existing tool and MCP warnings. + +The installed parent record contains the expanded component set and an +optional `bundle_references` JSON key listing each direct and transitive +reference once with its resolved catalog source. There are no child-bundle +records. `bundle update` follows the parent's current manifest and its pinned +references; remove and refresh retain the existing ownership and component +lifecycle rules. + +Referenced manifests require network access. `install`, `info`, and `update` +fail offline with the reference named; `validate --offline` warns once per +declared reference that it was not expanded. `bundle build` performs only the +structural checks for references and does not resolve or embed them. + +**Known limitations.** + +- Offline and local resolution of referenced bundles is not supported; referenced + manifests need network-backed catalog downloads. +- First-party bundle catalogs keep no release history, so an exact pin to a + first-party bundle can break when that bundle publishes its next release. +- A local bundle with references cannot be previewed with `bundle info`; local + preview is deferred. +- Broader schema versioning and CLI compatibility checks/ranges are deferred. + > **Step payloads resolve through the step catalog only.** A bundle's `provides.steps` entries still resolve exclusively through the active step catalogs. Bundle-local `steps//` payloads and relative `provides.steps[].source` overrides are **not** resolved in this release, so a step declared that way cannot be installed offline. To ship a step with a bundle today, publish it to a step catalog the bundle's users can reach. ## Catalog Release History @@ -176,7 +257,7 @@ specify bundle list | -------- | ---------------------------- | | `--json` | Emit machine-readable JSON | -Lists the bundles installed in the project with their versions, required component counts (including components supplied independently), and install timestamps. +Lists the bundles installed in the project with their versions, required component counts (including components supplied independently), and install timestamps. With `--json`, a reference-bearing record also includes `bundle_references`, containing each resolved direct or transitive reference and its winning catalog source. ## Initialize a Project with a Bundle @@ -204,7 +285,7 @@ specify bundle validate | `--path` | Bundle directory or `bundle.yml` (default: current directory) | | `--offline` | Verify references against bundled/installed components only | -Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins. +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. For a reference-bearing manifest, online validation expands the complete reference graph and checks every component in the expanded plan, including structural, cycle, version, source, conflict, and install-policy checks. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. In `--offline` mode, each declared bundle reference produces a warning that it was not expanded. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins. ## Build a Bundle Artifact diff --git a/src/specify_cli/bundles/_commands.py b/src/specify_cli/bundles/_commands.py index 512773b668..6f491d089e 100644 --- a/src/specify_cli/bundles/_commands.py +++ b/src/specify_cli/bundles/_commands.py @@ -139,7 +139,40 @@ def _resolve_init_integration(override: str | None, manifest) -> str: return resolve_default_init_integration() -def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]: +def _expand_manifest( + project_root: Path, + manifest, + *, + offline: bool, + stack=None, + download, +): + """Expand referenced bundles through the effective catalog stack. + + Flat manifests deliberately bypass stack construction: local bundles with + no references must retain their existing offline, no-network behavior. + ``download`` is supplied by the calling command so its established + monkeypatch point also controls referenced-manifest downloads. + """ + from .expansion import ExpandedManifest, expand_manifest + + if not manifest.bundles: + return ExpandedManifest.flat(manifest) + from .sources import CatalogBundleLoader + + if stack is None: + stack = _build_stack(project_root, offline=offline) + loader = CatalogBundleLoader(stack, offline=offline, download=download) + return expand_manifest(manifest, loader) + + +def _bundle_overlaps( + project_root: Path, + manifest, + *, + offline: bool, + components=None, +) -> list[str]: """Return informational overlaps between *manifest* and installed bundles.""" if manifest is None: return [] @@ -150,6 +183,7 @@ def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str manifest, active_integration(project_root), load_records(project_root), + components=components, ) return [*report.overlaps, *report.version_clashes] except BundlerError: diff --git a/src/specify_cli/bundles/catalog_stack.py b/src/specify_cli/bundles/catalog_stack.py index a3bed4bf2f..a8ea7df44d 100644 --- a/src/specify_cli/bundles/catalog_stack.py +++ b/src/specify_cli/bundles/catalog_stack.py @@ -22,6 +22,28 @@ CatalogFetcher = Callable[[CatalogSource], dict] +class ReleaseNotFoundError(BundlerError): + """A requested release is absent from the winning catalog entry.""" + + def __init__( + self, + bundle_id: str, + version: str, + source_id: str, + advertised: tuple[str, ...], + ) -> None: + self.bundle_id = bundle_id + self.version = version + self.source_id = source_id + self.advertised = advertised + offered = ", ".join(advertised) + super().__init__( + f"Bundle '{bundle_id}' has no release {version} in catalog source " + f"'{source_id}' (advertised: {offered}). Only the " + "highest-precedence source that lists a bundle is consulted." + ) + + @dataclass class ResolvedBundle: entry: CatalogEntry @@ -80,11 +102,11 @@ def resolve(self, bundle_id: str, version: str | None = None) -> ResolvedBundle: if entry is not None: selected = select_release(entry, version) if selected is None: - advertised = ", ".join(available_versions(entry)) - raise BundlerError( - f"Bundle '{bundle_id}' has no release {version} in catalog source " - f"'{source.id}' (advertised: {advertised}). Only the " - "highest-precedence source that lists a bundle is consulted." + raise ReleaseNotFoundError( + bundle_id=bundle_id, + version=version, + source_id=source.id, + advertised=tuple(available_versions(entry)), ) return ResolvedBundle( entry=selected.with_provenance(source), diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py index 9c5515e888..fcaa01df82 100644 --- a/src/specify_cli/bundles/command_info.py +++ b/src/specify_cli/bundles/command_info.py @@ -14,6 +14,7 @@ from ._commands import ( _build_stack, _bundle_overlaps, + _expand_manifest, _fail, _trust_badge, _trust_level, @@ -21,6 +22,7 @@ ) from .project import find_project_root from .sources import _download_manifest +from .versioning import same_version @bundle_app.command("info") @@ -77,12 +79,28 @@ def bundle_info( # degrading to catalog `provides` counts, so users never mistake an # unverifiable bundle for a known/installable one. manifest = _download_manifest(resolved, offline=offline) + expanded = _expand_manifest( + project_root, + manifest, + offline=offline, + stack=stack, + download=_download_manifest, + ) except BundlerError as exc: _fail(str(exc), as_json=as_json and isinstance(exc.__cause__, ProjectPolicyError)) return - overlaps = _bundle_overlaps(project_root, manifest, offline=offline) - components = _manifest_component_view(manifest) + if expanded.has_references: + overlaps = _bundle_overlaps( + project_root, + manifest, + offline=offline, + components=expanded.components, + ) + else: + # Preserve the flat command's established call shape and rendering. + overlaps = _bundle_overlaps(project_root, manifest, offline=offline) + components = _manifest_component_view(expanded) entry = resolved.entry if as_json: @@ -106,6 +124,8 @@ def bundle_info( "components": components, "overlaps": overlaps, } + if expanded.has_references: + payload["references"] = _reference_view(expanded) print(_json.dumps(payload, indent=2)) return @@ -133,6 +153,11 @@ def bundle_info( if manifest and manifest.integration: console.print(f" Integration: {_escape_markup(str(manifest.integration.id))}") + if expanded.has_references: + console.print("\n [bold]References:[/bold]") + for reference in expanded.references: + console.print(f" - {_escape_markup(_format_reference(reference))}") + if components: console.print("\n [bold]Components[/bold] (added on install):") for kind in ("extensions", "presets", "steps", "workflows"): @@ -161,25 +186,75 @@ def bundle_info( ) -def _manifest_component_view(manifest) -> list[dict]: - """Flatten a manifest's components to JSON-friendly dicts (id, version, ...).""" - if manifest is None: +def _manifest_component_view(expanded) -> list[dict]: + """Flatten an expanded manifest to JSON-friendly component dictionaries.""" + if expanded is None: return [] view: list[dict] = [] - for component in manifest.components: + for component in expanded.components: item = { "kind": component.kind, "id": component.id, "version": component.version, } + if expanded.has_references and component.source is not None: + item["source"] = component.source if component.priority is not None: item["priority"] = component.priority if component.strategy is not None: item["strategy"] = component.strategy + if expanded.has_references: + origin = expanded.origins.get((component.kind, component.id)) + if origin is not None and origin.origin: + item["origin"] = list(origin.origin) + if origin is not None and origin.overrides: + item["overrides"] = [ + _component_provenance(override.component, override.origin) + for override in origin.overrides + ] view.append(item) return view +def _component_provenance(component, origin: tuple[str, ...]) -> dict: + item = { + "version": component.version, + "origin": list(origin), + } + if component.source is not None: + item["source"] = component.source + if component.priority is not None: + item["priority"] = component.priority + if component.strategy is not None: + item["strategy"] = component.strategy + return item + + +def _reference_view(expanded) -> list[dict]: + return [ + { + "id": reference.id, + "version": reference.version, + "source": reference.source_id, + "install_policy": ( + "install-allowed" if reference.install_allowed else "discovery-only" + ), + "via": list(reference.via), + } + for reference in expanded.references + ] + + +def _format_reference(reference) -> str: + label = f"{reference.id} v{reference.version} ({reference.source_id}" + if reference.via: + label += f"; via {' → '.join(reference.via)}" + if not reference.install_allowed: + label += "; discovery-only, not installable" + label += ")" + return label + + def _format_component(item: dict) -> str: label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"] extras = [] @@ -187,6 +262,42 @@ def _format_component(item: dict) -> str: extras.append(f"priority={item['priority']}") if item.get("strategy") is not None: extras.append(f"strategy={item['strategy']}") + origin = item.get("origin") + if origin: + extras.append(f"from {' → '.join(origin)}") + for override in item.get("overrides", []): + extras.append(_format_override(item, override)) if extras: label += f" ({', '.join(extras)})" return label + + +def _format_override(item: dict, override: dict) -> str: + """Describe the settings replaced by an overriding component.""" + changed: list[str] = [] + old_version = override.get("version") + current_version = item.get("version") + versions_differ = (old_version is None) != (current_version is None) + if ( + versions_differ + or ( + old_version is not None + and current_version is not None + and not same_version(current_version, old_version) + ) + ): + changed.append(str(old_version) if old_version else "unpinned") + + for field in ("source", "priority", "strategy"): + if override.get(field) != item.get(field): + value = override.get(field) + changed.append(f"{field}={value if value is not None else ''}") + + # An override always differs in at least one install-affecting field, but + # retain the historical version-only marker if a caller supplies an + # incomplete provenance dictionary. + if not changed: + changed.append(str(old_version) if old_version else "unpinned") + marker = ", ".join(changed) + origin = " → ".join(override.get("origin", [])) + return f"overrides {marker} from {origin}" diff --git a/src/specify_cli/bundles/command_install.py b/src/specify_cli/bundles/command_install.py index 877adff55e..58bf01aee1 100644 --- a/src/specify_cli/bundles/command_install.py +++ b/src/specify_cli/bundles/command_install.py @@ -9,9 +9,11 @@ from .._console import console from . import BundlerError +from .expansion import require_installable from ._commands import ( _build_stack, _bundle_overlaps, + _expand_manifest, _default_script_type, _fail, _render_install_warnings, @@ -75,6 +77,7 @@ def bundle_install( from .resolver import resolve_install_plan project_root = find_project_root() + stack = None local_manifest = _local_manifest_source(bundle_id) if local_manifest is not None: @@ -94,6 +97,15 @@ def bundle_install( ) manifest = _download_manifest(resolved, offline=offline) + expanded = _expand_manifest( + project_root or Path.cwd(), + manifest, + offline=offline, + stack=stack, + download=_download_manifest, + ) + require_installable(expanded) + if project_root is None: init_integration = _resolve_init_integration(integration, manifest) # Resolve all hard compatibility gates before ``specify init``. @@ -101,7 +113,7 @@ def bundle_install( # initialize a project and only then fail its version/integration # checks, leaving state behind after a failed install. resolve_install_plan( - manifest, + expanded, speckit_version=_speckit_version(), active_integration=init_integration, integration_explicit=True, @@ -115,7 +127,12 @@ def bundle_install( ) project_root = require_project_root() - for overlap in _bundle_overlaps(project_root, manifest, offline=offline): + for overlap in _bundle_overlaps( + project_root, + manifest, + offline=offline, + components=expanded.components, + ): console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}") # For an already-initialized project, the project's recorded active @@ -125,7 +142,7 @@ def bundle_install( # target when the active integration cannot be determined. detected = active_integration(project_root) plan = resolve_install_plan( - manifest, + expanded, speckit_version=_speckit_version(), active_integration=detected if detected is not None else integration, integration_explicit=bool(integration) and detected is None, diff --git a/src/specify_cli/bundles/command_update.py b/src/specify_cli/bundles/command_update.py index db4d54adab..bb52af834e 100644 --- a/src/specify_cli/bundles/command_update.py +++ b/src/specify_cli/bundles/command_update.py @@ -7,8 +7,10 @@ from .._console import console from . import BundlerError +from .expansion import require_installable from ._commands import ( _build_stack, + _expand_manifest, _fail, _render_install_warnings, _speckit_version, @@ -56,13 +58,24 @@ def bundle_update( "Update requires an install-allowed source (FR-025)." ) manifest = _download_manifest(resolved, offline=offline) + expanded = _expand_manifest( + project_root, + manifest, + offline=offline, + stack=stack, + download=_download_manifest, + ) + require_installable(expanded) detected = active_integration(project_root) plan = resolve_install_plan( - manifest, + expanded, speckit_version=_speckit_version(), active_integration=detected if detected is not None else integration, integration_explicit=bool(integration) and detected is None, ) + if expanded.has_references: + for warning in plan.warnings: + console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") result = install_bundle( project_root, plan, installer, manifest=manifest, refresh=True ) diff --git a/src/specify_cli/bundles/command_validate.py b/src/specify_cli/bundles/command_validate.py index be7aeea3c8..5f473f57ba 100644 --- a/src/specify_cli/bundles/command_validate.py +++ b/src/specify_cli/bundles/command_validate.py @@ -9,7 +9,11 @@ from .._console import console from . import BundlerError -from ._commands import _fail, bundle_app +from ._commands import _expand_manifest, _fail, bundle_app +from .expansion import discovery_only_messages +# Kept at module scope as the command's download monkeypatch point for tests. +from .sources import _download_manifest +from .validator import ValidationReport @bundle_app.command("validate") @@ -26,19 +30,10 @@ def bundle_validate( """Report whether the manifest is well-formed and references resolve.""" try: manifest_path = _resolve_manifest_path(path) - from .project import find_project_root from .manifest import BundleManifest - from .references import make_reference_checker - from .validator import validate_manifest manifest = BundleManifest.from_file(manifest_path) - ref_root = find_project_root(manifest_path.parent) or Path.cwd() - ref_warnings: list[str] = [] - checker = make_reference_checker( - ref_root, allow_network=not offline, warnings=ref_warnings - ) - report = validate_manifest(manifest, reference_checker=checker) - report.warnings.extend(ref_warnings) + report = _validate(manifest, manifest_path, offline=offline) except BundlerError as exc: _fail(str(exc)) return @@ -56,6 +51,71 @@ def bundle_validate( ) +def _validate(manifest, manifest_path: Path, *, offline: bool) -> ValidationReport: + """Run structural, expansion, and component checks in stable phases.""" + from .project import find_project_root + from .references import make_reference_checker + from .validator import validate_manifest + + ref_root = find_project_root(manifest_path.parent) or Path.cwd() + ref_warnings: list[str] = [] + checker = make_reference_checker( + ref_root, allow_network=not offline, warnings=ref_warnings + ) + + # A flat manifest retains the historical single validation call and output. + if not manifest.bundles: + report = validate_manifest(manifest, reference_checker=checker) + report.warnings.extend(ref_warnings) + return report + + # Structural errors on a reference-bearing manifest must not resolve or + # download anything. The structural phase intentionally performs no + # reference checks. + structural = validate_manifest(manifest) + if structural.errors: + return structural + + if offline: + report = validate_manifest(manifest, reference_checker=checker) + report.warnings.extend(ref_warnings) + report.warnings.extend(_offline_reference_warnings(manifest)) + return report + + try: + expanded = _expand_manifest( + ref_root, + manifest, + offline=False, + download=_download_manifest, + ) + except BundlerError as exc: + # Structural checks already passed, so ``checked`` holds only the + # reference checker's own-component errors; the expansion error leads. + checked = validate_manifest(manifest, reference_checker=checker) + checked.errors.insert(0, str(exc)) + checked.warnings.extend(ref_warnings) + return checked + + report = validate_manifest( + manifest, + reference_checker=checker, + components=expanded.components, + ) + report.errors.extend(discovery_only_messages(expanded)) + report.warnings.extend(ref_warnings) + report.warnings.extend(expanded.warnings) + return report + + +def _offline_reference_warnings(manifest) -> list[str]: + return [ + f"Offline: bundle reference '{reference.id}@{reference.version}' " + f"({manifest.bundle.id} → {reference.id}) was not expanded." + for reference in manifest.bundles + ] + + def _resolve_manifest_path(path: Path | None) -> Path: target = (path or Path.cwd()).resolve() if target.is_dir(): diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index db79614486..b29c789a05 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field -from .manifest import BundleManifest +from .manifest import BundleManifest, ComponentRef from .records import InstalledBundleRecord from .versioning import same_version @@ -19,10 +19,28 @@ def has_blocking_conflict(self) -> bool: return self.integration_clash is not None or bool(self.version_clashes) +def component_setting_differences(a: ComponentRef, b: ComponentRef) -> list[str]: + """Return install-affecting setting fields that differ between two refs. + + Compares ``source`` and, for presets only, ``priority`` and ``strategy``. + Versions are intentionally not compared. + """ + different = [] + if a.source != b.source: + different.append("source") + if a.kind == "presets": + if a.priority != b.priority: + different.append("priority") + if a.strategy != b.strategy: + different.append("strategy") + return different + + def detect_conflicts( manifest: BundleManifest, active_integration: str | None, installed: list[InstalledBundleRecord], + components: list[ComponentRef] | None = None, ) -> ConflictReport: report = ConflictReport() @@ -43,7 +61,8 @@ def detect_conflicts( (record.bundle_id, component.version) ) - for component in manifest.components: + selected_components = manifest.components if components is None else components + for component in selected_components: for owner, version in already.get((component.kind, component.id), []): if owner == manifest.bundle.id: continue diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py new file mode 100644 index 0000000000..c501464102 --- /dev/null +++ b/src/specify_cli/bundles/expansion.py @@ -0,0 +1,548 @@ +"""Expansion of bundle references into a flat, provenance-aware component plan.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from . import BundlerError +from .conflict import component_setting_differences +from .manifest import COMPONENT_KINDS, BundleManifest, BundleRef, ComponentRef +from .versioning import same_version + +MAX_REFERENCE_DEPTH = 10 +MAX_REFERENCED_BUNDLES = 50 + + +@dataclass(frozen=True) +class LoadedBundle: + manifest: BundleManifest + source_id: str + install_allowed: bool + + +class BundleLoader(Protocol): + def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: ... + + +@dataclass(frozen=True) +class ResolvedReference: + id: str + version: str + source_id: str + install_allowed: bool + via: tuple[str, ...] + manifest: BundleManifest + + +class SourceMismatchError(BundlerError): + """A bundle reference does not match the winning catalog source.""" + + def __init__( + self, ref: BundleRef, actual_source: str, chain: tuple[str, ...] + ) -> None: + self.reference = ref + self.actual_source = actual_source + self.chain = chain + super().__init__( + f"Bundle reference {ref.id} ({format_chain(chain)}) requires source " + f"'{ref.source}', but the winning catalog is '{actual_source}'." + ) + + +@dataclass(frozen=True) +class ComponentOverride: + component: ComponentRef + origin: tuple[str, ...] + + +@dataclass(frozen=True) +class ComponentOrigin: + origin: tuple[str, ...] + overrides: tuple[ComponentOverride, ...] = () + + +@dataclass +class ExpandedManifest: + root: BundleManifest + components: list[ComponentRef] + origins: dict[tuple[str, str], ComponentOrigin] + references: list[ResolvedReference] + warnings: list[str] + + @classmethod + def flat(cls, manifest: BundleManifest) -> "ExpandedManifest": + return cls( + root=manifest, + components=list(manifest.components), + origins={ + (component.kind, component.id): ComponentOrigin(()) + for component in manifest.components + }, + references=[], + warnings=[], + ) + + @property + def has_references(self) -> bool: + return bool(self.references) + + +@dataclass(frozen=True) +class _Entry: + component: ComponentRef + origin: tuple[str, ...] + overrides: tuple[ComponentOverride, ...] = () + + +@dataclass(frozen=True) +class _OverrideNotice: + component: ComponentRef + declaring: tuple[str, ...] + replaced: tuple[_Entry, ...] + + +@dataclass(frozen=True) +class _Subplan: + entries: tuple[_Entry, ...] + reference_paths: dict[str, tuple[str, ...]] + longest_path: tuple[str, ...] + notices: tuple[_OverrideNotice, ...] + + +class _ExpansionState: + def __init__(self, loader: BundleLoader) -> None: + self.loader = loader + self.loaded: dict[str, LoadedBundle] = {} + self.subplans: dict[str, _Subplan] = {} + self.versions: dict[str, tuple[str, tuple[str, ...]]] = {} + self.references: list[ResolvedReference] = [] + self._reference_indexes: dict[str, int] = {} + self.distinct_ids: set[str] = set() + + def register_reference( + self, ref: BundleRef, loaded: LoadedBundle, chain: tuple[str, ...] + ) -> None: + via = chain[1:] + reference = ResolvedReference( + id=ref.id, + version=ref.version or "", + source_id=loaded.source_id, + install_allowed=loaded.install_allowed, + via=via, + manifest=loaded.manifest, + ) + index = self._reference_indexes.get(ref.id) + if index is None: + self._reference_indexes[ref.id] = len(self.references) + self.references.append(reference) + else: + previous = self.references[index] + if len(via) < len(previous.via): + self.references[index] = reference + + def shorten_cached_references( + self, subplan: _Subplan, chain: tuple[str, ...] + ) -> None: + """Apply a newly discovered shorter route to cached reference metadata.""" + for reference_id, path in subplan.reference_paths.items(): + index = self._reference_indexes.get(reference_id) + if index is None: + continue + previous = self.references[index] + via = chain[1:] + path[:-1] + if len(via) < len(previous.via): + self.references[index] = ResolvedReference( + id=previous.id, + version=previous.version, + source_id=previous.source_id, + install_allowed=previous.install_allowed, + via=via, + manifest=previous.manifest, + ) + + +def format_chain(chain: tuple[str, ...]) -> str: + return " → ".join(chain) + + +def expand_manifest(root: BundleManifest, loader: BundleLoader) -> ExpandedManifest: + """Expand *root* using *loader*, without performing any I/O itself.""" + if not root.bundles: + return ExpandedManifest.flat(root) + + state = _ExpansionState(loader) + subplan = _expand(root, (root.bundle.id,), state) + components = [entry.component for entry in subplan.entries] + origins = { + (entry.component.kind, entry.component.id): ComponentOrigin( + entry.origin, entry.overrides + ) + for entry in subplan.entries + } + warnings = _format_warnings(root, subplan.notices) + return ExpandedManifest(root, components, origins, state.references, warnings) + + +def _expand( + manifest: BundleManifest, chain: tuple[str, ...], state: _ExpansionState +) -> _Subplan: + """Compute one completed subplan for a bundle (never cache partial results).""" + cached = state.subplans.get(manifest.bundle.id) + if cached is not None: + return cached + + candidates: dict[tuple[str, str], list[_Entry]] = {} + ordered: list[tuple[str, str]] = [] + reference_paths: dict[str, tuple[str, ...]] = {} + notices: list[_OverrideNotice] = [] + conflicts: list[str] = [] + longest_path: tuple[str, ...] = () + + own_keys = {(component.kind, component.id) for component in manifest.components} + + for ref in manifest.bundles: + if len(chain) > MAX_REFERENCE_DEPTH: + raise BundlerError( + f"Bundle reference depth exceeds {MAX_REFERENCE_DEPTH}: " + f"{format_chain(chain + (ref.id,))}." + ) + if ref.id in chain: + raise BundlerError( + f"Bundle reference cycle: {format_chain(chain + (ref.id,))}" + ) + + old_version = state.versions.get(ref.id) + if old_version is not None and not same_version( + old_version[0], ref.version or "" + ): + raise BundlerError( + f"Bundle '{ref.id}' is referenced at versions {old_version[0]} " + f"({format_chain(old_version[1])}) and {ref.version or ''} " + f"({format_chain(chain)})." + ) + if old_version is None: + state.versions[ref.id] = (ref.version or "", chain) + + if ref.id not in state.distinct_ids: + if len(state.distinct_ids) >= MAX_REFERENCED_BUNDLES: + raise BundlerError( + f"Bundle reference expansion exceeds the limit of " + f"{MAX_REFERENCED_BUNDLES} distinct bundles " + f"({format_chain(chain + (ref.id,))})." + ) + state.distinct_ids.add(ref.id) + + loaded = state.loaded.get(ref.id) + if loaded is None: + try: + loaded = state.loader.load(ref, chain) + except SourceMismatchError: + # The loader has already included the reference chain and the + # winning catalog in this diagnostic. Do not add a second + # "Cannot resolve ..." wrapper. + raise + except BundlerError as exc: + raise BundlerError( + f"Cannot resolve bundle reference " + f"{ref.id}@{ref.version or ''} " + f"({format_chain(chain)}): {exc}" + ) from exc + if loaded.manifest.bundle.id != ref.id: + raise BundlerError( + f"Bundle reference {ref.id} loaded manifest for " + f"'{loaded.manifest.bundle.id}'." + ) + if not same_version(loaded.manifest.bundle.version, ref.version or ""): + raise BundlerError( + f"Bundle reference {ref.id}@{ref.version or ''} " + f"loaded manifest version {loaded.manifest.bundle.version}." + ) + state.loaded[ref.id] = loaded + + if ref.source is not None and ref.source != loaded.source_id: + raise _source_mismatch(ref, loaded, chain) + + state.register_reference(ref, loaded, chain) + child = state.subplans.get(ref.id) + if child is None: + child = _expand(loaded.manifest, chain + (ref.id,), state) + # longest_path excludes the child itself. This check is deliberately + # repeated for cache hits: a shallow first route cannot evade a limit. + if len(chain) + len(child.longest_path) > MAX_REFERENCE_DEPTH: + raise BundlerError( + f"Bundle reference depth exceeds {MAX_REFERENCE_DEPTH}: " + f"{format_chain(chain + (ref.id,) + child.longest_path)}." + ) + state.shorten_cached_references(child, chain + (ref.id,)) + + child_prefix = (ref.id,) + if len(child_prefix + child.longest_path) > len(longest_path): + longest_path = child_prefix + child.longest_path + _shortest_path(reference_paths, ref.id, child_prefix) + for descendant, path in child.reference_paths.items(): + _shortest_path(reference_paths, descendant, child_prefix + path) + for child_entry in child.entries: + entry = _prefix_entry(child_entry, child_prefix) + _merge_candidate( + entry, + candidates, + ordered, + own_keys, + conflicts, + chain, + ) + for notice in child.notices: + _append_notice(notices, _prefix_notice(notice, child_prefix)) + + # Apply the current bundle's own declarations after all referenced plans. + for component in manifest.components: + key = component.kind, component.id + own_entry = _Entry(component, ()) + existing = candidates.get(key, []) + if not existing: + candidates[key] = [own_entry] + ordered.append(key) + continue + + replaced: list[_Entry] = [] + inherited_overrides: list[ComponentOverride] = [] + for entry in existing: + inherited_overrides.extend(entry.overrides) + if _component_differences(entry.component, component): + replaced.append(entry) + # Removing and re-adding gives an override the position of the own + # declaration, rather than the first referenced occurrence. + ordered.remove(key) + candidates[key] = [ + _Entry(component, (), tuple(inherited_overrides)) + ] + ordered.append(key) + if replaced: + direct_overrides = tuple( + ComponentOverride(entry.component, entry.origin) for entry in replaced + ) + candidates[key] = [ + _Entry(component, (), tuple(inherited_overrides) + direct_overrides) + ] + _append_notice( + notices, + _OverrideNotice(component, (), tuple(replaced)), + ) + + if conflicts: + raise BundlerError("Bundle component conflicts:\n" + "\n".join(conflicts)) + + entries = tuple(candidates[key][0] for key in _stable_keys(ordered)) + result = _Subplan( + entries=entries, + reference_paths=dict(reference_paths), + longest_path=longest_path, + notices=tuple(notices), + ) + state.subplans[manifest.bundle.id] = result + return result + + +def _merge_candidate( + entry: _Entry, + candidates: dict[tuple[str, str], list[_Entry]], + ordered: list[tuple[str, str]], + own_keys: set[tuple[str, str]], + conflicts: list[str], + declaring_chain: tuple[str, ...], +) -> None: + key = entry.component.kind, entry.component.id + existing = candidates.get(key) + if existing is None: + candidates[key] = [entry] + ordered.append(key) + return + + differences = [ + _component_differences(previous.component, entry.component) + for previous in existing + ] + if any(differences) and key not in own_keys: + # Compare each newly encountered spelling to the first spelling. This + # reports every differing field while keeping one diagnostic per pair. + previous = existing[ + next(index for index, fields in enumerate(differences) if fields) + ] + conflicts.append(_format_conflict(previous, entry, declaring_chain)) + return + + # A shared dependency can arrive through multiple routes. Merge an + # agreeing copy into the existing candidate instead of retaining a + # duplicate override entry. Preserve the first component spelling while + # taking the shorter provenance and its inherited overrides. + for index, fields in enumerate(differences): + if not fields: + previous = existing[index] + if len(entry.origin) < len(previous.origin): + existing[index] = _Entry( + previous.component, entry.origin, entry.overrides + ) + return + + if key in own_keys: + existing.append(entry) + return + + +def _stable_keys(ordered: list[tuple[str, str]]) -> list[tuple[str, str]]: + kind_order = {kind: index for index, kind in enumerate(COMPONENT_KINDS)} + return sorted(ordered, key=lambda key: kind_order.get(key[0], len(kind_order))) + + +def _prefix_entry(entry: _Entry, prefix: tuple[str, ...]) -> _Entry: + return _Entry( + entry.component, + prefix + entry.origin, + tuple( + ComponentOverride(override.component, prefix + override.origin) + for override in entry.overrides + ), + ) + + +def _prefix_notice(notice: _OverrideNotice, prefix: tuple[str, ...]) -> _OverrideNotice: + return _OverrideNotice( + notice.component, + prefix + notice.declaring, + tuple( + _Entry(entry.component, prefix + entry.origin, entry.overrides) + for entry in notice.replaced + ), + ) + + +def _notice_key(notice: _OverrideNotice) -> tuple[str | None, str, str]: + return ( + notice.declaring[-1] if notice.declaring else None, + notice.component.kind, + notice.component.id, + ) + + +def _append_notice(notices: list[_OverrideNotice], notice: _OverrideNotice) -> None: + key = _notice_key(notice) + for index, previous in enumerate(notices): + if _notice_key(previous) == key: + if len(notice.declaring) < len(previous.declaring): + notices[index] = notice + return + notices.append(notice) + + +def _shortest_path( + paths: dict[str, tuple[str, ...]], key: str, path: tuple[str, ...] +) -> None: + old = paths.get(key) + if old is None or len(path) < len(old): + paths[key] = path + + +def _component_differences(a: ComponentRef, b: ComponentRef) -> list[str]: + different: list[str] = [] + if a.version is None or b.version is None: + if a.version != b.version: + different.append("version") + elif not same_version(a.version, b.version): + different.append("version") + different.extend(component_setting_differences(a, b)) + return different + + +def _source_mismatch( + ref: BundleRef, loaded: LoadedBundle, chain: tuple[str, ...] +) -> SourceMismatchError: + return SourceMismatchError(ref, loaded.source_id, chain) + + +def _display_value(field: str, value: object) -> str: + if value is None: + return "" if field == "version" else "" + return str(value) + + +def _field_text(component: ComponentRef, field: str) -> str: + return f"{field} {_display_value(field, getattr(component, field))}" + + +def _component_name(component: ComponentRef) -> str: + return f"{component.kind[:-1]} '{component.id}'" + + +def _format_conflict( + a: _Entry, b: _Entry, declaring_chain: tuple[str, ...] +) -> str: + fields = _component_differences(a.component, b.component) + left = "; ".join(_field_text(a.component, field) for field in fields) + right = "; ".join(_field_text(b.component, field) for field in fields) + left_chain = format_chain(declaring_chain + a.origin) + right_chain = format_chain(declaring_chain + b.origin) + return ( + f"{_component_name(a.component)}: {left} ({left_chain}) vs {right} " + f"({right_chain}); declare '{a.component.id}' in " + f"'{declaring_chain[-1]}' to choose." + ) + + +def _format_warnings( + root: BundleManifest, notices: tuple[_OverrideNotice, ...] +) -> list[str]: + warnings: list[str] = [] + for notice in notices: + own_chain = format_chain((root.bundle.id,) + notice.declaring) + fields_by_entry: list[str] = [] + differences_by_entry = [ + _component_differences(replaced.component, notice.component) + for replaced in notice.replaced + ] + for replaced, fields in zip(notice.replaced, differences_by_entry): + old_chain = format_chain((root.bundle.id,) + replaced.origin) + fields_by_entry.append( + " and ".join( + f"{field} " + f"{_display_value(field, getattr(replaced.component, field))} " + f"from {old_chain}" + for field in fields + ) + ) + fields = list( + dict.fromkeys( + field + for differences in differences_by_entry + for field in differences + ) + ) + own_fields = " and ".join( + f"{field} {_display_value(field, getattr(notice.component, field))}" + for field in fields + ) + warnings.append( + f"{_component_name(notice.component)} {own_fields} ({own_chain}) " + f"overrides {' and '.join(fields_by_entry)}." + ) + return warnings + + +def discovery_only_messages(expanded: ExpandedManifest) -> list[str]: + """Return install-policy errors for references from discovery-only sources.""" + messages: list[str] = [] + for reference in expanded.references: + if reference.install_allowed: + continue + chain = (expanded.root.bundle.id,) + reference.via + (reference.id,) + messages.append( + f"Bundle '{reference.id}' ({format_chain(chain)}) resolves only from " + f"discovery-only source '{reference.source_id}'; it cannot be " + "installed from there." + ) + return messages + + +def require_installable(expanded: ExpandedManifest) -> None: + messages = discovery_only_messages(expanded) + if messages: + raise BundlerError(messages[0]) diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 6428c140da..a4087e25c4 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -16,8 +16,8 @@ from typing import Protocol from . import BundlerError -from .conflict import detect_conflicts -from .manifest import BundleManifest, ComponentRef +from .conflict import component_setting_differences, detect_conflicts +from .manifest import BundleManifest, BundleRef, ComponentRef from .primitives import BundledPatchWarning from .records import ( InstalledBundleRecord, @@ -103,7 +103,12 @@ def install_bundle( records = load_records(project_root) if manifest is not None: - report = detect_conflicts(manifest, plan.effective_integration, records) + report = detect_conflicts( + manifest, + plan.effective_integration, + records, + components=plan.components, + ) if report.has_blocking_conflict: raise BundlerError( "; ".join( @@ -235,6 +240,10 @@ def install_bundle( # ``bundle list`` keeps reporting when the bundle was first installed. installed_at=existing.installed_at if existing is not None else None, installed_versions=installed_versions, + bundle_references=[ + BundleRef(reference.id, reference.version, reference.source_id) + for reference in plan.references + ], ) updated = upsert_record(records, record) if refresh and existing is not None: @@ -321,14 +330,7 @@ def _check_installed_pins( for component in plan.components: key = component.kind, component.id for bundle_id, required in other_requirements.get(key, []): - different = [] - if component.source != required.source: - different.append("source") - if component.kind == "presets": - if component.priority != required.priority: - different.append("priority") - if component.strategy != required.strategy: - different.append("strategy") + different = component_setting_differences(component, required) if different: raise BundlerError( f"Cannot install or refresh shared {component.kind[:-1]} " diff --git a/src/specify_cli/bundles/manifest.py b/src/specify_cli/bundles/manifest.py index 2811a31b96..ea749ee371 100644 --- a/src/specify_cli/bundles/manifest.py +++ b/src/specify_cli/bundles/manifest.py @@ -15,7 +15,7 @@ from .versioning import is_semver from .yamlio import load_yaml -SUPPORTED_SCHEMA_VERSIONS = {"1.0"} +SUPPORTED_SCHEMA_VERSIONS = {"1.0", "1.1"} PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} COMPONENT_KINDS = ("extensions", "presets", "steps", "workflows") @@ -41,6 +41,18 @@ def label(self) -> str: return f"{self.kind[:-1]}:{self.id}@{self.version or 'unpinned'}" +@dataclass(frozen=True) +class BundleRef: + """A pointer to another bundle, pinned to an exact version.""" + + id: str + version: str | None = None + source: str | None = None # expected winning bundle catalog ID + + def label(self) -> str: + return f"bundle:{self.id}@{self.version or 'unpinned'}" + + @dataclass(frozen=True) class IntegrationRef: id: str @@ -74,12 +86,16 @@ class BundleManifest: presets: list[ComponentRef] = field(default_factory=list) steps: list[ComponentRef] = field(default_factory=list) workflows: list[ComponentRef] = field(default_factory=list) + bundles: list[BundleRef] = field(default_factory=list) tags: tuple[str, ...] = () source_path: Path | None = None @property def components(self) -> list[ComponentRef]: - """All installable component references in deterministic order.""" + """Own installable component references in deterministic order. + + Excludes ``bundles``: referenced bundles are expanded separately. + """ return [*self.extensions, *self.presets, *self.steps, *self.workflows] # -- construction --------------------------------------------------------- @@ -153,6 +169,7 @@ def from_dict(cls, data: Any) -> BundleManifest: presets=_parse_refs("presets", provides.get("presets")), steps=_parse_refs("steps", provides.get("steps")), workflows=_parse_refs("workflows", provides.get("workflows")), + bundles=_parse_bundle_refs(provides.get("bundles")), tags=tuple(str(t) for t in tags_raw), ) return manifest @@ -223,6 +240,41 @@ def structural_errors(self) -> list[str]: f"got {ref.priority}." ) + if self.bundles and self.schema_version != "1.1": + errors.append('Bundle references require schema_version "1.1".') + + seen_bundles: set[str] = set() + for bundle_ref in self.bundles: + if not bundle_ref.id: + errors.append("A bundle reference is missing its 'id'.") + else: + if not _SAFE_BUNDLE_ID.match(bundle_ref.id): + errors.append( + f"Bundle reference id '{bundle_ref.id}' must be a slug " + "(lowercase letters, digits, '.', '_', '-'; no path separators)." + ) + if bundle_ref.id in seen_bundles: + errors.append( + f"Duplicate bundle reference '{bundle_ref.id}' in provides; " + "declare each bundle reference only once." + ) + seen_bundles.add(bundle_ref.id) + if bundle_ref.id == self.bundle.id: + errors.append( + f"Bundle reference '{bundle_ref.id}' is invalid: " + "a bundle cannot reference itself." + ) + if not bundle_ref.version: + errors.append( + f"bundle reference '{bundle_ref.id or ''}' " + "must be pinned to a 'version'." + ) + elif not is_semver(bundle_ref.version): + errors.append( + f"bundle reference '{bundle_ref.id}' has invalid version " + f"'{bundle_ref.version}'." + ) + for ref in self.presets: if ref.priority is None: errors.append(f"preset '{ref.id}' must declare an integer 'priority'.") @@ -294,6 +346,25 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]: return refs +def _parse_bundle_refs(raw: Any) -> list[BundleRef]: + if raw is None: + return [] + if not isinstance(raw, list): + raise BundlerError("provides.bundles must be a list when present.") + refs: list[BundleRef] = [] + for item in raw: + if not isinstance(item, dict): + raise BundlerError("Each provides.bundles entry must be a mapping.") + refs.append( + BundleRef( + id=_text(item.get("id")), + version=(str(item["version"]).strip() if item.get("version") else None), + source=(str(item["source"]).strip() if item.get("source") else None), + ) + ) + return refs + + def _parse_priority(kind: str, raw: Any) -> int | None: if raw is None: return None diff --git a/src/specify_cli/bundles/records.py b/src/specify_cli/bundles/records.py index 86660f6f0d..1ef376beb4 100644 --- a/src/specify_cli/bundles/records.py +++ b/src/specify_cli/bundles/records.py @@ -16,7 +16,7 @@ from . import BundlerError from .yamlio import dump_json, ensure_within, load_json -from .manifest import COMPONENT_KINDS, ComponentRef, _text +from .manifest import COMPONENT_KINDS, BundleRef, ComponentRef, _text RECORDS_FILENAME = "bundle-records.json" RECORDS_SCHEMA_VERSION = "1.0" @@ -30,6 +30,7 @@ class InstalledBundleRecord: required_components: tuple[ComponentRef, ...] installed_at: str installed_versions: dict[str, str] = field(default_factory=dict) + bundle_references: tuple[BundleRef, ...] = () @classmethod def create( @@ -40,6 +41,7 @@ def create( installed_at: str | None = None, required_components: list[ComponentRef] | None = None, installed_versions: dict[str, str] | None = None, + bundle_references: list[BundleRef] | None = None, ) -> "InstalledBundleRecord": return cls( bundle_id=bundle_id, @@ -52,6 +54,9 @@ def create( installed_versions=( dict(installed_versions) if installed_versions is not None else {} ), + bundle_references=( + tuple(bundle_references) if bundle_references is not None else () + ), ) def to_dict(self) -> dict[str, Any]: @@ -68,6 +73,11 @@ def to_dict(self) -> dict[str, Any]: } if self.installed_versions: data["installed_versions"] = dict(self.installed_versions) + if self.bundle_references: + data["bundle_references"] = [ + _bundle_reference_to_dict(reference) + for reference in self.bundle_references + ] return data @classmethod @@ -121,6 +131,16 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord": installed_versions = data.get("installed_versions", {}) if not isinstance(installed_versions, dict): raise BundlerError("Corrupt record: 'installed_versions' must be a mapping.") + bundle_references_raw = data.get("bundle_references") + if bundle_references_raw is None: + bundle_references: tuple[BundleRef, ...] = () + elif not isinstance(bundle_references_raw, list): + raise BundlerError("Corrupt record: 'bundle_references' must be a list.") + else: + bundle_references = tuple( + _bundle_reference_from_dict(reference) + for reference in bundle_references_raw + ) keys = {f"{c.kind}:{c.id}" for c in required} for key, actual in installed_versions.items(): if key not in keys or not isinstance(actual, str) or not actual.strip(): @@ -135,6 +155,7 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord": contributed_components=contributed, required_components=required, installed_versions=dict(installed_versions), + bundle_references=bundle_references, ) @@ -282,6 +303,45 @@ def _component_to_dict(ref: ComponentRef) -> dict[str, Any]: return data +def _bundle_reference_to_dict(reference: BundleRef) -> dict[str, Any]: + return { + "id": reference.id, + "version": reference.version, + "source": reference.source, + } + + +def _bundle_reference_from_dict(data: Any) -> BundleRef: + if not isinstance(data, dict): + raise BundlerError( + "Corrupt record: 'bundle_references' entries must be mappings." + ) + bundle_id = data.get("id") + version = data.get("version") + if ( + not isinstance(bundle_id, str) + or not bundle_id.strip() + or not isinstance(version, str) + or not version.strip() + ): + raise BundlerError( + "Corrupt record: 'bundle_references' entries must have non-empty " + "string 'id' and 'version'." + ) + source = data.get("source") + if source == "": + source = None + elif source is not None and not isinstance(source, str): + raise BundlerError( + "Corrupt record: 'bundle_references' entry 'source' must be a string." + ) + return BundleRef( + id=bundle_id, + version=version, + source=source, + ) + + def _component_from_dict(data: Any, *, label: str = "contributed") -> ComponentRef: if not isinstance(data, dict): raise BundlerError(f"Each {label} component must be a mapping.") diff --git a/src/specify_cli/bundles/resolver.py b/src/specify_cli/bundles/resolver.py index 0e8f4ec274..befc8c6803 100644 --- a/src/specify_cli/bundles/resolver.py +++ b/src/specify_cli/bundles/resolver.py @@ -12,6 +12,7 @@ from . import BundlerError from .versioning import satisfies +from .expansion import ExpandedManifest, ResolvedReference, format_chain from .manifest import BundleManifest, ComponentRef @@ -23,6 +24,7 @@ class InstallPlan: effective_integration: str | None components: list[ComponentRef] = field(default_factory=list) warnings: list[str] = field(default_factory=list) + references: list[ResolvedReference] = field(default_factory=list) @property def component_count(self) -> int: @@ -41,14 +43,14 @@ def grouped(self) -> dict[str, list[ComponentRef]]: def resolve_install_plan( - manifest: BundleManifest, + source: ExpandedManifest | BundleManifest, *, speckit_version: str, active_integration: str | None, integration_explicit: bool = False, enforce_version: bool = True, ) -> InstallPlan: - """Expand *manifest* into an :class:`InstallPlan`, enforcing gates. + """Expand *source* into an :class:`InstallPlan`, enforcing gates. Raises :class:`BundlerError` when a hard gate fails (version gate, integration clash). Soft issues are collected in ``plan.warnings``. @@ -60,6 +62,16 @@ def resolve_install_plan( explicit override, resolution fails instead of silently adopting the bundle's required integration (FR-019 guard). """ + if isinstance(source, BundleManifest): + if source.bundles: + raise BundlerError( + "Cannot resolve a bundle manifest with references before expansion." + ) + expanded = ExpandedManifest.flat(source) + else: + expanded = source + + manifest = expanded.root structural = manifest.structural_errors() if structural: raise BundlerError( @@ -107,20 +119,68 @@ def resolve_install_plan( effective_integration = required warnings: list[str] = [] - if manifest.requires.tools: - warnings.append( - "Requires external tools: " + ", ".join(manifest.requires.tools) - ) - if manifest.requires.mcp: - warnings.append("Requires MCP servers: " + ", ".join(manifest.requires.mcp)) + if expanded.references: + tools: list[str] = [] + mcp: list[str] = [] + requirements_to_merge = [ + manifest.requires, + *(reference.manifest.requires for reference in expanded.references), + ] + for requirements in requirements_to_merge: + for tool in requirements.tools: + if tool not in tools: + tools.append(tool) + for server in requirements.mcp: + if server not in mcp: + mcp.append(server) + else: + # A flat bundle is deliberately byte-compatible with the pre-reference + # resolver: duplicate declarations remain visible in warning output. + tools = list(manifest.requires.tools) + mcp = list(manifest.requires.mcp) + if tools: + warnings.append("Requires external tools: " + ", ".join(tools)) + if mcp: + warnings.append("Requires MCP servers: " + ", ".join(mcp)) + + for reference in expanded.references: + reference_manifest = reference.manifest + chain = (manifest.bundle.id,) + reference.via + (reference.id,) + if enforce_version and reference_manifest.requires.speckit_version: + if not satisfies( + speckit_version, reference_manifest.requires.speckit_version + ): + raise BundlerError( + f"Bundle '{reference.id}' ({format_chain(chain)}) requires " + f"Spec Kit {reference_manifest.requires.speckit_version}, " + f"but this project uses {speckit_version}. Update Spec Kit or " + "choose a compatible bundle." + ) + + if reference_manifest.integration is not None: + required = reference_manifest.integration.id + if effective_integration is None: + warnings.append( + f"Bundle '{reference.id}' ({format_chain(chain)}) targets " + f"integration '{required}'; the project's integration could " + "not be determined." + ) + elif required != effective_integration: + warnings.append( + f"Bundle '{reference.id}' ({format_chain(chain)}) targets " + f"'{required}'; this install uses '{effective_integration}'." + ) + + warnings.extend(expanded.warnings) return InstallPlan( bundle_id=manifest.bundle.id, version=manifest.bundle.version, role=manifest.bundle.role, effective_integration=effective_integration, - components=list(manifest.components), + components=list(expanded.components), warnings=warnings, + references=list(expanded.references), ) diff --git a/src/specify_cli/bundles/sources.py b/src/specify_cli/bundles/sources.py index 3a6ebe5d4c..c5d92c2c5f 100644 --- a/src/specify_cli/bundles/sources.py +++ b/src/specify_cli/bundles/sources.py @@ -11,6 +11,9 @@ read_response_limited, ) from . import BundlerError +from .catalog_stack import ReleaseNotFoundError +from .expansion import LoadedBundle, SourceMismatchError +from .manifest import BundleRef from .versioning import same_version # ZIP magic-byte signatures cover local headers, empty archives, and spanning markers. @@ -88,6 +91,67 @@ def _local_manifest_source(arg: str): ) +class CatalogBundleLoader: + """Load referenced bundles from an already-resolved catalog stack. + + The caller owns construction of the effective stack. In particular, this + loader must not create a second stack: source precedence and project/user + overrides are part of the resolution contract for bundle references. + """ + + def __init__(self, stack, *, offline: bool, download=None) -> None: + self._stack = stack + self._offline = offline + self._download = download + + def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: + """Resolve and download the exact release named by *ref*. + + A source pin is an assertion about the winning catalog, rather than a + selector. Consequently it is checked after stack resolution and + before the manifest download; a mismatch never falls through to a + lower-precedence catalog or performs network I/O. + """ + try: + resolved = self._stack.resolve(ref.id, ref.version) + except ReleaseNotFoundError as exc: + missing = _missing_release_error(ref, chain, exc) + if missing is not None: + raise missing from exc + raise + + if ref.source and resolved.source.id != ref.source: + raise SourceMismatchError(ref, resolved.source.id, chain) + + download = self._download or _download_manifest + manifest = download(resolved, offline=self._offline) + + return LoadedBundle( + manifest, + resolved.source.id, + resolved.install_allowed, + ) + + +def _missing_release_error(ref, chain, error: ReleaseNotFoundError): + """Format the catalog stack's exact-release failure for bundle authors. + + ``CatalogStack.resolve`` provides the advertised versions and winning source + as structured exception attributes, so this translation does not depend on + the wording of its diagnostic message. + """ + if ref.version is None: + return None + + parent = chain[-1] if chain else "" + offered = ", ".join(error.advertised) + return BundlerError( + f"'{parent}' pins {error.bundle_id} {error.version}, but catalog " + f"'{error.source_id}' offers {offered}. Update the reference to an " + "advertised version." + ) + + def _download_manifest(resolved, *, offline: bool): """Resolve a bundle's manifest from its catalog ``download_url``. diff --git a/src/specify_cli/bundles/validator.py b/src/specify_cli/bundles/validator.py index 0259b1800a..9b0c999421 100644 --- a/src/specify_cli/bundles/validator.py +++ b/src/specify_cli/bundles/validator.py @@ -35,6 +35,7 @@ def merge(self, other: "ValidationReport") -> None: def validate_manifest( manifest: BundleManifest, reference_checker: ReferenceChecker | None = None, + components: list[ComponentRef] | None = None, ) -> ValidationReport: report = ValidationReport() @@ -50,7 +51,8 @@ def validate_manifest( ) if reference_checker is not None: - for component in manifest.components: + selected_components = manifest.components if components is None else components + for component in selected_components: problem = reference_checker(component) if problem: report.errors.append( diff --git a/tests/specify_cli/bundles/helpers.py b/tests/specify_cli/bundles/helpers.py index 0d793ea5a7..75ba7608a6 100644 --- a/tests/specify_cli/bundles/helpers.py +++ b/tests/specify_cli/bundles/helpers.py @@ -12,7 +12,9 @@ import yaml -from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles import BundlerError +from specify_cli.bundles.expansion import LoadedBundle +from specify_cli.bundles.manifest import BundleRef, ComponentRef def valid_manifest_dict(**overrides) -> dict: @@ -43,6 +45,24 @@ def valid_manifest_dict(**overrides) -> dict: return data +def reference_manifest_dict( + bundles: list[dict] | None = None, + *, + bundle_id: str = "demo-bundle", + schema_version: str = "1.1", + **provides, +) -> dict: + """Return a manifest dict whose ``provides.bundles`` holds *bundles*. + + Own components are omitted unless passed as keyword lists (e.g. + ``extensions=[...]``), so the default is a reference-only bundle. + """ + data = valid_manifest_dict(schema_version=schema_version) + data["bundle"]["id"] = bundle_id + data["provides"] = {"bundles": bundles or [], **provides} + return data + + def bundled_extension_version(extension_id: str) -> str: """Version declared by the bundled extension the primitives will install. @@ -110,6 +130,22 @@ def write_catalog_file(path: Path, bundles: dict) -> Path: return path +class FakeBundleLoader: + """Deterministic in-memory loader for bundle expansion tests.""" + + def __init__(self, bundles: dict[tuple[str, str], LoadedBundle]) -> None: + self.bundles = bundles + self.load_calls: list[tuple[str, str]] = [] + + def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: + key = (ref.id, ref.version or "") + self.load_calls.append(key) + try: + return self.bundles[key] + except KeyError: + raise BundlerError(f"unknown bundle {ref.id}@{ref.version or ''}") from None + + class FakeInstaller: """Deterministic in-memory PrimitiveInstaller for offline integration tests.""" diff --git a/tests/specify_cli/bundles/test_catalog_stack.py b/tests/specify_cli/bundles/test_catalog_stack.py index 6ffaa37e0d..2082593107 100644 --- a/tests/specify_cli/bundles/test_catalog_stack.py +++ b/tests/specify_cli/bundles/test_catalog_stack.py @@ -6,7 +6,7 @@ from specify_cli.bundler import BundlerError from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy, Scope -from specify_cli.bundles.catalog_stack import CatalogStack +from specify_cli.bundles.catalog_stack import CatalogStack, ReleaseNotFoundError from tests.specify_cli.bundles.helpers import catalog_entry_dict, catalog_payload @@ -119,13 +119,19 @@ def test_resolve_does_not_fall_through_to_lower_source_with_the_version(): {"high": catalog_payload({"history": high}), "low": catalog_payload({"history": low})}, ) - with pytest.raises(BundlerError) as excinfo: + with pytest.raises(ReleaseNotFoundError) as excinfo: stack.resolve("history", "0.9.0") - message = str(excinfo.value) - assert "no release 0.9.0 in catalog source 'high'" in message - assert "(advertised: 2.0.0, 1.0.0)" in message - assert "highest-precedence source" in message + error = excinfo.value + assert error.bundle_id == "history" + assert error.version == "0.9.0" + assert error.source_id == "high" + assert error.advertised == ("2.0.0", "1.0.0") + assert str(error) == ( + "Bundle 'history' has no release 0.9.0 in catalog source 'high' " + "(advertised: 2.0.0, 1.0.0). Only the highest-precedence source that " + "lists a bundle is consulted." + ) def test_resolve_keeps_discovery_only_winner_with_the_version(): diff --git a/tests/specify_cli/bundles/test_command_build.py b/tests/specify_cli/bundles/test_command_build.py index 0b721d9593..c3289415ad 100644 --- a/tests/specify_cli/bundles/test_command_build.py +++ b/tests/specify_cli/bundles/test_command_build.py @@ -2,9 +2,11 @@ import io # noqa: F401 import json # noqa: F401 +import zipfile from pathlib import Path from unittest.mock import patch # noqa: F401 +import pytest import yaml # noqa: F401 from typer.testing import CliRunner @@ -18,6 +20,41 @@ runner = CliRunner() +def test_build_reference_only_bundle_offline(project: Path, monkeypatch): + data = valid_manifest_dict(schema_version="1.1") + data["provides"] = {"bundles": [{"id": "child", "version": "1.0.0"}]} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (project / "README.md").write_text("# Parent", encoding="utf-8") + + monkeypatch.setattr( + "socket.create_connection", + lambda *args, **kwargs: pytest.fail("build attempted network access"), + ) + result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")]) + + assert result.exit_code == 0, result.output + artifacts = list((project / "dist").glob("*.zip")) + assert artifacts + with zipfile.ZipFile(artifacts[0]) as archive: + built_manifest = yaml.safe_load(archive.read("bundle.yml")) + assert built_manifest["provides"]["bundles"] == [ + {"id": "child", "version": "1.0.0"} + ] + + +def test_build_rejects_reference_with_schema_1_0(project: Path): + data = valid_manifest_dict(schema_version="1.0") + data["provides"] = {"bundles": [{"id": "child", "version": "1.0.0"}]} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (project / "README.md").write_text("# Parent", encoding="utf-8") + + result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")]) + + assert result.exit_code == 1 + assert "Bundle references require schema_version" in result.output + assert not list((project / "dist").glob("*.zip")) + + def test_build_produces_artifact(project: Path): (project / "bundle.yml").write_text( yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 8ca00a5cc9..99ad5d4f07 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -10,7 +10,8 @@ from typer.testing import CliRunner from specify_cli import app -from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.command_info import _format_component +from specify_cli.bundles.manifest import BundleManifest, ComponentRef from specify_cli.bundles.packager import build_bundle # noqa: F401 from specify_cli.bundles.records import InstalledBundleRecord, save_records from tests.conftest import strip_ansi # noqa: F401 @@ -22,6 +23,7 @@ ) from tests.specify_cli.bundles.helpers import ( catalog_entry_dict, + reference_manifest_dict, valid_manifest_dict, write_catalog_file, ) @@ -74,11 +76,137 @@ def test_info_unknown_bundle_without_project_reports_not_found( assert "Spec Kit project" not in result.output +def test_info_renders_reference_provenance(project: Path, monkeypatch): + parent_dir = project / "parent-bundle" + child_dir = project / "child-bundle" + parent_dir.mkdir() + child_dir.mkdir() + parent_data = reference_manifest_dict( + [{"id": "child-bundle", "version": "1.0.0"}], + extensions=[{"id": "child-extension", "version": "2.0.0"}], + ) + child_data = reference_manifest_dict( + [{"id": "grandchild-bundle", "version": "1.0.0"}], + bundle_id="child-bundle", + extensions=[{"id": "child-extension", "version": "1.0.0"}], + ) + child_data["bundle"]["version"] = "1.0.0" + parent_path = parent_dir / "bundle.yml" + child_path = child_dir / "bundle.yml" + grandchild_dir = project / "grandchild-bundle" + grandchild_dir.mkdir() + grandchild_data = reference_manifest_dict( + [], bundle_id="grandchild-bundle", + extensions=[{"id": "grandchild-extension", "version": "1.0.0"}], + ) + grandchild_data["bundle"]["version"] = "1.0.0" + grandchild_path = grandchild_dir / "bundle.yml" + parent_path.write_text(yaml.safe_dump(parent_data), encoding="utf-8") + child_path.write_text(yaml.safe_dump(child_data), encoding="utf-8") + grandchild_path.write_text(yaml.safe_dump(grandchild_data), encoding="utf-8") + catalog = project / "references-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict("demo-bundle"), + "child-bundle": catalog_entry_dict( + "child-bundle", version="1.0.0", download_url="https://example.com/child.zip" + ), + "grandchild-bundle": catalog_entry_dict( + "grandchild-bundle", version="1.0.0", download_url="https://example.com/grandchild.zip" + ), + }, + ) + added = runner.invoke(app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]) + assert added.exit_code == 0, added.output + + def fake_download(resolved, *, offline): + paths = { + "demo-bundle": parent_path, + "child-bundle": child_path, + "grandchild-bundle": grandchild_path, + } + return BundleManifest.from_file(paths[resolved.entry.id]) + + monkeypatch.setattr("specify_cli.bundles.command_info._download_manifest", fake_download) + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["references"] == [ + { + "id": "child-bundle", + "version": "1.0.0", + "source": "local", + "install_policy": "install-allowed", + "via": [], + }, + { + "id": "grandchild-bundle", + "version": "1.0.0", + "source": "local", + "install_policy": "install-allowed", + "via": ["child-bundle"], + }, + ] + component = next(c for c in payload["components"] if c["id"] == "child-extension") + assert "origin" not in component + assert component["overrides"] == [{"version": "1.0.0", "origin": ["child-bundle"]}] + transitive = next(c for c in payload["components"] if c["id"] == "grandchild-extension") + assert transitive["origin"] == ["child-bundle", "grandchild-bundle"] + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert "References:" in text.output + assert " - child-bundle v1.0.0 (local)" in text.output + assert " - grandchild-bundle v1.0.0 (local; via child-bundle)" in text.output + assert "overrides 1.0.0 from child-bundle" in text.output + + +def test_format_component_describes_setting_overrides(): + text = _format_component( + { + "id": "shared", + "version": "1.0.0", + "source": "winning", + "priority": 10, + "strategy": "replace", + "overrides": [ + { + "version": "v1.0.0", + "source": "lower", + "priority": 5, + "strategy": "append", + "origin": ["child-bundle"], + } + ], + } + ) + + assert "overrides source=lower, priority=5, strategy=append from child-bundle" in text + assert "overrides 1.0.0 from child-bundle" not in text + + +def test_format_component_does_not_claim_unpinned_version_changed(): + text = _format_component( + { + "id": "shared", + "version": None, + "source": "winning", + "overrides": [ + {"version": None, "source": "lower", "origin": ["child-bundle"]} + ], + } + ) + + assert "overrides source=lower from child-bundle" in text + assert "overrides unpinned" not in text + + def test_info_expands_full_component_set(project: Path, monkeypatch): bundle_dir = project / "src-bundle" bundle_dir.mkdir() + manifest_data = valid_manifest_dict() + manifest_data["provides"]["extensions"][0]["source"] = "cat" (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + yaml.safe_dump(manifest_data), encoding="utf-8" ) catalog = project / "local-catalog.json" entry = catalog_entry_dict( @@ -98,11 +226,13 @@ def test_info_expands_full_component_set(project: Path, monkeypatch): payload = json.loads(result.output) components = {(c["kind"], c["id"]): c for c in payload["components"]} assert ("extensions", "ext-a") in components + assert "source" not in components[("extensions", "ext-a")] preset = components[("presets", "preset-a")] assert preset["version"] == "2.0.0" assert preset["priority"] == 10 assert preset["strategy"] == "append" assert payload["trust"] == "verified" + assert "references" not in payload save_records( project, @@ -300,6 +430,68 @@ def test_info_escapes_catalog_markup(project: Path, monkeypatch): assert value in output +def test_info_escapes_reference_and_component_markup(project: Path, monkeypatch): + parent_path = project / "markup-parent" / "bundle.yml" + child_path = project / "markup-child" / "bundle.yml" + parent_path.parent.mkdir() + child_path.parent.mkdir() + parent_path.write_text( + yaml.safe_dump( + reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="parent" + ) + ), + encoding="utf-8", + ) + child_data = valid_manifest_dict( + bundle={ + "id": "child", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + } + ) + child_data["provides"]["extensions"][0]["id"] = "[bold]x[/bold]" + child_path.write_text(yaml.safe_dump(child_data), encoding="utf-8") + catalog = project / "markup-reference-catalog.json" + write_catalog_file( + catalog, + { + "parent": catalog_entry_dict("parent"), + "child": catalog_entry_dict( + "child", version="1.0.0", download_url="https://example.com/child.zip" + ), + }, + ) + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "catalogs": [{ + "id": MARKUP_SOURCE_ID, + "url": str(catalog), + "priority": 1, + "install_policy": "install-allowed", + }], + } + ), + encoding="utf-8", + ) + + def fake_download(resolved, *, offline): + return BundleManifest.from_file( + parent_path if resolved.entry.id == "parent" else child_path + ) + + monkeypatch.setattr("specify_cli.bundles.command_info._download_manifest", fake_download) + result = runner.invoke(app, ["bundle", "info", "parent", "--offline"]) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + assert "References:" in output + assert MARKUP_SOURCE_ID in output + assert "[bold]x[/bold] v1.0.0" in output + + def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch): markup_count = "[bold]markup-count[/bold]" _configure_markup_catalog( @@ -360,6 +552,76 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch): assert ("extensions", "ext-a") in components +def test_info_expands_discovery_only_referenced_bundle(project: Path, monkeypatch): + parent_dir = project / "disc-parent" + child_dir = project / "disc-child" + parent_dir.mkdir() + child_dir.mkdir() + parent_path = parent_dir / "bundle.yml" + child_path = child_dir / "bundle.yml" + parent_path.write_text( + yaml.safe_dump( + reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="demo-bundle" + ) + ), + encoding="utf-8", + ) + child_path.write_text( + yaml.safe_dump( + valid_manifest_dict( + bundle={ + "id": "child", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + } + ) + ), + encoding="utf-8", + ) + catalog = project / "disc-reference-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict("demo-bundle"), + "child": catalog_entry_dict( + "child", version="1.0.0", download_url="https://example.com/child.zip" + ), + }, + ) + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "catalogs": [{ + "id": "disc", + "url": str(catalog), + "priority": 1, + "install_policy": "discovery-only", + }], + } + ), + encoding="utf-8", + ) + + def fake_download(resolved, *, offline): + return BundleManifest.from_file( + parent_path if resolved.entry.id == "demo-bundle" else child_path + ) + + monkeypatch.setattr("specify_cli.bundles.command_info._download_manifest", fake_download) + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["references"][0]["install_policy"] == "discovery-only" + components = {(c["kind"], c["id"]) for c in payload["components"]} + assert ("extensions", "ext-a") in components + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert "discovery-only, not installable" in text.output + + def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch): # A .zip artifact is extracted to read bundle.yml; info expands it. (The # download itself is HTTPS-only now and mocked here — see contract note.) @@ -412,6 +674,42 @@ def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path): assert "Network access disabled" in result.output +def test_info_reference_offline_error_names_child(project: Path, monkeypatch): + parent = project / "parent" / "bundle.yml" + parent.parent.mkdir() + parent.write_text( + yaml.safe_dump(reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="demo-bundle" + )), + encoding="utf-8", + ) + catalog = project / "reference-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict("demo-bundle"), + "child": catalog_entry_dict( + "child", version="1.0.0", download_url="https://example.com/child.zip" + ), + }, + ) + _make_catalog_config(catalog, project) + from specify_cli.bundles import command_info + + original_download = command_info._download_manifest + + def download(resolved, *, offline): + if resolved.entry.id == "demo-bundle": + return BundleManifest.from_file(parent) + return original_download(resolved, offline=offline) + + monkeypatch.setattr(command_info, "_download_manifest", download) + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + + assert result.exit_code == 1 + assert "Cannot resolve bundle reference child@1.0.0" in result.output + + def test_bundle_info_resolves_github_browser_release_url(project: Path): """bundle info resolves a private-repo browser release URL via the GitHub API.""" browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml" diff --git a/tests/specify_cli/bundles/test_command_install.py b/tests/specify_cli/bundles/test_command_install.py index ad50c6f8ae..87a3b925b3 100644 --- a/tests/specify_cli/bundles/test_command_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -5,6 +5,7 @@ import shutil import zipfile from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -12,6 +13,8 @@ from typer.testing import CliRunner from specify_cli import app +from specify_cli.bundler import BundlerError +from specify_cli.bundles import _commands, command_install from specify_cli.bundles._commands import _resolve_init_integration from specify_cli.bundles.manifest import BundleManifest from specify_cli.bundles.packager import build_bundle @@ -21,6 +24,7 @@ catalog_entry_dict, make_project, valid_manifest_dict, + reference_manifest_dict, write_catalog_file, write_manifest, ) @@ -132,6 +136,313 @@ def test_local_firstparty_bundle_installs_bundled_components_offline( assert registry["workflows"][bundle_id]["version"] == "1.0.0" +def test_local_parent_installs_referenced_catalog_components( + project: Path, monkeypatch +): + """A local parent expands through the same catalog stack as a catalog root.""" + child_version = bundled_extension_version("agent-context") + child = BundleManifest.from_dict( + valid_manifest_dict( + bundle={ + "id": "child-pack", + "name": "Child Pack", + "version": "1.0.0", + "role": "developer", + "description": "child", + "author": "tests", + "license": "MIT", + }, + provides={ + "extensions": [{"id": "agent-context", "version": child_version}] + }, + ) + ) + parent_path = write_manifest( + project / "parent-pack", + reference_manifest_dict( + [{"id": "child-pack", "version": "1.0.0"}], + bundle_id="parent-pack", + ), + ) + + resolved = SimpleNamespace( + entry=SimpleNamespace(id="child-pack"), + source=SimpleNamespace(id="test-catalog"), + install_allowed=True, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + assert (bundle_id, version) == ("child-pack", "1.0.0") + return resolved + + monkeypatch.setattr(_commands, "_build_stack", lambda *args, **kwargs: Stack()) + monkeypatch.setattr( + command_install, + "_download_manifest", + lambda selected, *, offline: child, + ) + + result = runner.invoke(app, ["bundle", "add", str(parent_path), "--offline"]) + + assert result.exit_code == 0, result.output + assert (project / ".specify/extensions/agent-context/extension.yml").is_file() + from specify_cli.bundles.records import load_records + + record = load_records(project)[0] + assert record.bundle_id == "parent-pack" + assert [(c.kind, c.id) for c in record.required_components] == [ + ("extensions", "agent-context") + ] + assert record.bundle_references[0].id == "child-pack" + + +def test_install_rejects_reference_source_mismatch(project: Path, monkeypatch): + parent_path = write_manifest( + project / "parent-pack", + reference_manifest_dict( + [{"id": "child-pack", "version": "1.0.0", "source": "X"}], + bundle_id="parent-pack", + ), + ) + selected = SimpleNamespace( + entry=SimpleNamespace(id="child-pack"), + source=SimpleNamespace(id="winner"), + install_allowed=True, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return selected + + monkeypatch.setattr(_commands, "_build_stack", lambda *a, **k: Stack()) + result = runner.invoke(app, ["bundle", "install", str(parent_path), "--offline"]) + + assert result.exit_code == 1, result.output + assert "requires source" in result.output + + +def test_catalog_root_reference_install_builds_stack_once( + project: Path, monkeypatch +): + child_version = bundled_extension_version("agent-context") + parent = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": "child-pack", "version": "1.0.0"}], bundle_id="parent-pack" + ) + ) + child_data = valid_manifest_dict( + provides={"extensions": [{"id": "agent-context", "version": child_version}]} + ) + child_data["bundle"] = { + "id": "child-pack", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + } + child = BundleManifest.from_dict(child_data) + selected = { + "parent-pack": SimpleNamespace( + entry=SimpleNamespace(id="parent-pack"), + source=SimpleNamespace(id="default"), + install_allowed=True, + ), + "child-pack": SimpleNamespace( + entry=SimpleNamespace(id="child-pack"), + source=SimpleNamespace(id="default"), + install_allowed=True, + ), + } + builds = 0 + + class Stack: + def resolve(self, bundle_id, version=None): + return selected[bundle_id] + + def build_stack(*args, **kwargs): + nonlocal builds + builds += 1 + return Stack() + + monkeypatch.setattr(command_install, "_build_stack", build_stack) + monkeypatch.setattr( + command_install, + "_download_manifest", + lambda resolved, *, offline: parent if resolved.entry.id == "parent-pack" else child, + ) + + result = runner.invoke(app, ["bundle", "install", "parent-pack", "--offline"]) + + assert result.exit_code == 0, result.output + assert builds == 1 + assert (project / ".specify/extensions/agent-context/extension.yml").is_file() + + +def test_flat_local_install_never_builds_catalog_stack(project: Path, monkeypatch): + bundle = write_manifest(project / "flat", valid_manifest_dict(provides={})) + + monkeypatch.setattr( + command_install, + "_build_stack", + lambda *args, **kwargs: pytest.fail("flat local install built a catalog stack"), + ) + + result = runner.invoke(app, ["bundle", "install", str(bundle), "--offline"]) + + assert result.exit_code == 0, result.output + + +def test_reference_only_parent_installs_without_own_components( + project: Path, monkeypatch +): + child = BundleManifest.from_dict( + valid_manifest_dict( + bundle={ + "id": "child-pack", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + }, + provides={"extensions": [{"id": "child-ext", "version": "1.0.0"}]}, + ) + ) + parent_path = write_manifest( + project / "reference-only-parent", + reference_manifest_dict( + [{"id": "child-pack", "version": "1.0.0"}], + bundle_id="reference-only-parent", + ), + ) + resolved = SimpleNamespace( + entry=SimpleNamespace(id="child-pack"), + source=SimpleNamespace(id="test-catalog"), + install_allowed=True, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return resolved + + monkeypatch.setattr(_commands, "_build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr(command_install, "_download_manifest", lambda *a, **k: child) + installer = FakeInstaller() + monkeypatch.setattr( + "specify_cli.bundles.adapters.DefaultPrimitiveInstaller", + lambda **kwargs: installer, + ) + result = runner.invoke(app, ["bundle", "install", str(parent_path), "--offline"]) + assert result.exit_code == 0, result.output + assert installer.install_calls == [("extensions", "child-ext")] + + +def test_no_project_reference_gate_runs_before_init(tmp_path: Path, monkeypatch): + empty = tmp_path / "empty" + empty.mkdir() + monkeypatch.chdir(empty) + child_data = valid_manifest_dict( + bundle={ + "id": "child-pack", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + }, + requires={"speckit_version": ">=999.0.0"}, + provides={"extensions": []}, + ) + child = BundleManifest.from_dict(child_data) + parent = write_manifest( + tmp_path / "parent", + reference_manifest_dict( + [{"id": "child-pack", "version": "1.0.0"}], bundle_id="parent" + ), + ) + resolved = SimpleNamespace( + entry=SimpleNamespace(id="child-pack"), + source=SimpleNamespace(id="test-catalog"), + install_allowed=True, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return resolved + + monkeypatch.setattr(_commands, "_build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr(command_install, "_download_manifest", lambda *a, **k: child) + with patch("specify_cli.bundles.command_install._run_init") as run_init: + result = runner.invoke(app, ["bundle", "install", str(parent), "--offline"]) + assert result.exit_code == 1 + assert "Bundle 'child-pack'" in result.output + run_init.assert_not_called() + assert not (empty / ".specify").exists() + + +def test_discovery_only_reference_is_rejected_with_parent_chain( + project: Path, monkeypatch +): + child = BundleManifest.from_dict( + valid_manifest_dict( + bundle={ + "id": "child-pack", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + }, + provides={"extensions": []}, + ) + ) + parent = write_manifest( + project / "parent", + reference_manifest_dict( + [{"id": "child-pack", "version": "1.0.0"}], bundle_id="parent" + ), + ) + resolved = SimpleNamespace( + entry=SimpleNamespace(id="child-pack"), + source=SimpleNamespace(id="community"), + install_allowed=False, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return resolved + + monkeypatch.setattr(_commands, "_build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr(command_install, "_download_manifest", lambda *a, **k: child) + result = runner.invoke(app, ["bundle", "install", str(parent), "--offline"]) + assert result.exit_code == 1 + assert "discovery-only" in result.output + assert "parent → child-pack" in result.output + assert not (project / ".specify/bundle-records.json").exists() + + +def test_offline_reference_failure_names_reference_and_chain( + project: Path, monkeypatch +): + parent = write_manifest( + project / "parent", + reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="parent" + ), + ) + resolved = SimpleNamespace( + entry=SimpleNamespace(id="child"), + source=SimpleNamespace(id="default"), + install_allowed=True, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return resolved + + def offline_download(selected, *, offline): + raise BundlerError("Network access disabled") + + monkeypatch.setattr(_commands, "_build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr(command_install, "_download_manifest", offline_download) + result = runner.invoke(app, ["bundle", "install", str(parent), "--offline"]) + assert result.exit_code == 1 + assert "Cannot resolve bundle reference child@1.0.0" in result.output + assert "child@1.0.0 (parent):" in result.output + assert "Network access" in result.output + assert not (project / ".specify/bundle-records.json").exists() + + def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): bundle_dir = project / "mismatched-workflow-pin" (bundle_dir / "bundle.yml").parent.mkdir() diff --git a/tests/specify_cli/bundles/test_command_list.py b/tests/specify_cli/bundles/test_command_list.py index 20c2bdcf2e..8db9250440 100644 --- a/tests/specify_cli/bundles/test_command_list.py +++ b/tests/specify_cli/bundles/test_command_list.py @@ -68,6 +68,29 @@ def test_list_escapes_markup_in_records(project: Path): assert "2026-01-01T00:00:00Z[/dim]" in output +def test_list_json_includes_bundle_references(project: Path): + record = { + "bundle_id": "parent", + "version": "1.0.0", + "installed_at": "2026-01-01T00:00:00Z", + "contributed_components": [], + "bundle_references": [ + {"id": "child", "version": "1.0.0", "source": "default"} + ], + } + (project / ".specify" / "bundle-records.json").write_text( + json.dumps({"schema_version": "1.0", "bundles": [record]}), + encoding="utf-8", + ) + + result = runner.invoke(app, ["bundle", "list", "--json"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)[0]["bundle_references"] == [ + {"id": "child", "version": "1.0.0", "source": "default"} + ] + + @pytest.mark.parametrize( ("contributed", "required", "expected_count"), [ diff --git a/tests/specify_cli/bundles/test_command_update.py b/tests/specify_cli/bundles/test_command_update.py index 6452f5c73c..d23f01741b 100644 --- a/tests/specify_cli/bundles/test_command_update.py +++ b/tests/specify_cli/bundles/test_command_update.py @@ -3,6 +3,7 @@ import io # noqa: F401 import json # noqa: F401 from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch # noqa: F401 import yaml # noqa: F401 @@ -12,7 +13,10 @@ from specify_cli.bundles.packager import build_bundle # noqa: F401 from tests.conftest import strip_ansi # noqa: F401 from tests.specify_cli.bundles.helpers import ( + FakeInstaller, catalog_entry_dict, + reference_manifest_dict, + valid_manifest_dict, write_catalog_file, ) @@ -69,3 +73,282 @@ def test_update_refuses_discovery_only_source(project: Path): result = runner.invoke(app, ["bundle", "update", "demo", "--offline"]) assert result.exit_code == 1 assert "discovery-only" in result.output + + +def test_update_refresh_removes_dropped_referenced_component( + project: Path, monkeypatch +): + from specify_cli.bundles.manifest import BundleManifest, ComponentRef + from specify_cli.bundles.records import ( + InstalledBundleRecord, + load_records, + save_records, + ) + + component = ComponentRef("extensions", "ext-a", "1.0.0") + save_records( + project, + [ + InstalledBundleRecord.create( + "parent", + "1.0.0", + [component], + required_components=[component], + ) + ], + ) + installer = FakeInstaller() + installer.installed.add((component.kind, component.id)) + installer.versions[(component.kind, component.id)] = component.version + monkeypatch.setattr( + "specify_cli.bundles.adapters.DefaultPrimitiveInstaller", + lambda **kwargs: installer, + ) + + parent = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="parent" + ) + ) + child_data = reference_manifest_dict([], bundle_id="child") + child_data["bundle"]["version"] = "1.0.0" + child = BundleManifest.from_dict(child_data) + selected = { + "parent": SimpleNamespace( + entry=SimpleNamespace(id="parent"), + source=SimpleNamespace(id="test-catalog"), + install_allowed=True, + ), + "child": SimpleNamespace( + entry=SimpleNamespace(id="child"), + source=SimpleNamespace(id="test-catalog"), + install_allowed=True, + ), + } + + class Stack: + def resolve(self, bundle_id, version=None): + return selected[bundle_id] + + monkeypatch.setattr( + "specify_cli.bundles.command_update._build_stack", + lambda *a, **k: Stack(), + ) + monkeypatch.setattr( + "specify_cli.bundles.command_update._download_manifest", + lambda resolved, *, offline: parent if resolved.entry.id == "parent" else child, + ) + + result = runner.invoke(app, ["bundle", "update", "parent", "--offline"]) + + assert result.exit_code == 0, result.output + assert installer.remove_calls == [("extensions", "ext-a")] + assert not load_records(project)[0].required_components + + +def test_update_follows_parent_reference_pin_bump(project: Path, monkeypatch): + from specify_cli.bundles.manifest import BundleManifest, ComponentRef + from specify_cli.bundles.records import InstalledBundleRecord, load_records, save_records + + old_component = ComponentRef("extensions", "child-ext", "1.0.0") + save_records( + project, + [InstalledBundleRecord.create("parent", "1.0.0", [old_component])], + ) + installer = FakeInstaller() + installer.installed.add((old_component.kind, old_component.id)) + installer.versions[(old_component.kind, old_component.id)] = old_component.version + monkeypatch.setattr( + "specify_cli.bundles.adapters.DefaultPrimitiveInstaller", + lambda **kwargs: installer, + ) + parent = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": "child", "version": "2.0.0"}], bundle_id="parent" + ) + ) + child_data = valid_manifest_dict( + bundle={ + "id": "child", "name": "Child", "version": "2.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + }, + provides={"extensions": [{"id": "child-ext", "version": "2.0.0"}]}, + ) + child = BundleManifest.from_dict(child_data) + selected = { + "parent": SimpleNamespace(entry=SimpleNamespace(id="parent"), source=SimpleNamespace(id="default"), install_allowed=True), + "child": SimpleNamespace(entry=SimpleNamespace(id="child"), source=SimpleNamespace(id="default"), install_allowed=True), + } + + class Stack: + def resolve(self, bundle_id, version=None): + return selected[bundle_id] + + monkeypatch.setattr( + "specify_cli.bundles.command_update._build_stack", lambda *a, **k: Stack() + ) + monkeypatch.setattr( + "specify_cli.bundles.command_update._download_manifest", + lambda resolved, *, offline: parent if resolved.entry.id == "parent" else child, + ) + result = runner.invoke(app, ["bundle", "update", "parent", "--offline"]) + assert result.exit_code == 0, result.output + assert installer.refresh_calls == [("extensions", "child-ext")] + record = load_records(project)[0] + assert record.bundle_references[0].version == "2.0.0" + assert record.required_components[0].version == "2.0.0" + + +def test_update_escapes_reference_plan_warning_markup( + project: Path, monkeypatch +): + from specify_cli.bundles.manifest import BundleManifest, ComponentRef + from specify_cli.bundles.primitives import _bundled_pin_warning + from specify_cli.bundles.records import InstalledBundleRecord, save_records + + existing = ComponentRef("extensions", "[bold]x[/bold]", "1.0.0") + save_records(project, [InstalledBundleRecord.create("parent", "1.0.0", [existing])]) + + class WarningInstaller(FakeInstaller): + def refresh(self, root, component): + from specify_cli._console import console + + console.print("REFRESH_MARKER") + super().refresh(root, component) + return _bundled_pin_warning("Extension", component, "2.0.1") + + installer = WarningInstaller() + installer.installed.add((existing.kind, existing.id)) + installer.versions[(existing.kind, existing.id)] = "2.0.0" + monkeypatch.setattr( + "specify_cli.bundles.adapters.DefaultPrimitiveInstaller", + lambda **kwargs: installer, + ) + parent_data = reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="parent", + extensions=[{"id": "[bold]x[/bold]", "version": "2.0.0"}], + ) + parent_data["requires"] = { + "speckit_version": ">=0.1.0", "tools": ["docker"], "mcp": ["server-a"] + } + parent = BundleManifest.from_dict(parent_data) + child_data = valid_manifest_dict( + bundle={ + "id": "child", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + }, + integration={"id": "claude"}, + provides={"extensions": [{"id": "[bold]x[/bold]", "version": "1.0.0"}]}, + ) + child = BundleManifest.from_dict(child_data) + selected = { + "parent": SimpleNamespace(entry=SimpleNamespace(id="parent"), source=SimpleNamespace(id="default"), install_allowed=True), + "child": SimpleNamespace(entry=SimpleNamespace(id="child"), source=SimpleNamespace(id="default"), install_allowed=True), + } + + class Stack: + def resolve(self, bundle_id, version=None): + return selected[bundle_id] + + monkeypatch.setattr( + "specify_cli.bundles.command_update._build_stack", lambda *a, **k: Stack() + ) + monkeypatch.setattr( + "specify_cli.bundles.command_update.active_integration", lambda root: "copilot" + ) + monkeypatch.setattr( + "specify_cli.bundles.command_update._download_manifest", + lambda resolved, *, offline: parent if resolved.entry.id == "parent" else child, + ) + result = runner.invoke(app, ["bundle", "update", "parent", "--offline"]) + assert result.exit_code == 0, result.output + output = " ".join(result.output.split()) + assert output.count("Requires external tools: docker") == 1 + assert output.count("Requires MCP servers: server-a") == 1 + assert output.count("targets 'claude'") == 1 + assert output.count("overrides") == 1 + assert "[bold]x[/bold]" in strip_ansi(result.output) + assert output.count("Warning:") == 1 + marker = output.index("REFRESH_MARKER") + assert output.index("Requires external tools") < marker + assert output.index("Requires MCP servers") < marker + assert output.index("targets 'claude'") < marker + assert output.index("overrides") < marker + assert marker < output.index("Warning:") + + +def test_update_rejects_discovery_only_reference(project: Path, monkeypatch): + from specify_cli.bundles.manifest import BundleManifest + from specify_cli.bundles.records import InstalledBundleRecord, save_records + + save_records(project, [InstalledBundleRecord.create("parent", "1.0.0", [])]) + parent = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="parent" + ) + ) + child = BundleManifest.from_dict(valid_manifest_dict( + bundle={ + "id": "child", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + }, + provides={"extensions": []}, + )) + selected = { + "parent": SimpleNamespace(entry=SimpleNamespace(id="parent"), source=SimpleNamespace(id="default"), install_allowed=True), + "child": SimpleNamespace(entry=SimpleNamespace(id="child"), source=SimpleNamespace(id="community"), install_allowed=False), + } + + class Stack: + def resolve(self, bundle_id, version=None): + return selected[bundle_id] + + monkeypatch.setattr( + "specify_cli.bundles.command_update._build_stack", lambda *a, **k: Stack() + ) + monkeypatch.setattr( + "specify_cli.bundles.command_update._download_manifest", + lambda resolved, *, offline: parent if resolved.entry.id == "parent" else child, + ) + result = runner.invoke(app, ["bundle", "update", "parent", "--offline"]) + assert result.exit_code == 1 + assert "parent → child" in result.output + assert "discovery-only" in result.output + + +def test_update_flat_plan_warnings_keep_existing_output(project: Path, monkeypatch): + from specify_cli.bundles.manifest import BundleManifest + from specify_cli.bundles.records import InstalledBundleRecord, save_records + + save_records(project, [InstalledBundleRecord.create("flat", "1.0.0", [])]) + data = valid_manifest_dict() + data["bundle"]["id"] = "flat" + data["requires"]["tools"] = ["docker"] + manifest = BundleManifest.from_dict(data) + selected = SimpleNamespace( + entry=SimpleNamespace(id="flat"), source=SimpleNamespace(id="default"), + install_allowed=True, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return selected + + monkeypatch.setattr( + "specify_cli.bundles.command_update._build_stack", lambda *a, **k: Stack() + ) + monkeypatch.setattr( + "specify_cli.bundles.command_update._download_manifest", + lambda resolved, *, offline: manifest, + ) + monkeypatch.setattr( + "specify_cli.bundles.adapters.DefaultPrimitiveInstaller", + lambda **kwargs: FakeInstaller(), + ) + result = runner.invoke(app, ["bundle", "update", "flat", "--offline"]) + assert result.exit_code == 0, result.output + assert "Requires external tools" not in result.output + assert "Updated 'flat' to v1.2.0." in result.output diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index a2d57913e9..981cd490c7 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -3,6 +3,7 @@ import io # noqa: F401 import json # noqa: F401 from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch # noqa: F401 import pytest @@ -10,11 +11,16 @@ from typer.testing import CliRunner from specify_cli import app +from specify_cli.bundler import BundlerError +from specify_cli.bundles.manifest import BundleManifest from specify_cli.bundles.packager import build_bundle # noqa: F401 from tests.conftest import strip_ansi from tests.specify_cli.bundles.helpers import ( bundled_extension_version, + catalog_entry_dict, + reference_manifest_dict, valid_manifest_dict, + write_catalog_file, ) runner = CliRunner() @@ -269,3 +275,209 @@ def test_validate_rejects_mismatched_bundled_component( assert result.exit_code == 1, result.output assert f"resolved version is {version}" in result.output + + +def test_validate_expands_references_and_checks_expanded_components(project: Path, monkeypatch): + parent = reference_manifest_dict([{"id": "child", "version": "1.0.0"}], bundle_id="parent") + child = valid_manifest_dict( + bundle={"id": "child", "name": "Child", "version": "1.0.0", "role": "developer", "description": "child", "author": "tests", "license": "MIT"}, + provides={"extensions": [{"id": "child-extension", "version": "1.0.0"}]}, + ) + path = project / "bundle.yml" + path.write_text(yaml.safe_dump(parent), encoding="utf-8") + selected = SimpleNamespace(entry=SimpleNamespace(id="child", version="1.0.0"), source=SimpleNamespace(id="default"), install_allowed=True) + + class Stack: + def resolve(self, bundle_id, version=None): + return selected + + monkeypatch.setattr("specify_cli.bundles._commands._build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr("specify_cli.bundles.command_validate._download_manifest", lambda *a, **k: BundleManifest.from_dict(child)) + monkeypatch.setattr("specify_cli.bundles.references.make_reference_checker", lambda *a, **k: lambda component: "synthetic unresolved component" if component.id == "child-extension" else None) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 1, result.output + assert "child-extension" in result.output + assert "synthetic unresolved component" in " ".join(result.output.split()) + + +def test_validate_offline_warns_once_per_direct_reference(project: Path): + data = valid_manifest_dict(schema_version="1.1", provides={"bundles": [ + {"id": "first-child", "version": "1.0.0"}, + {"id": "second-child", "version": "1.0.0"}, + ]}) + path = project / "bundle.yml" + path.write_text(yaml.safe_dump(data), encoding="utf-8") + result = runner.invoke(app, ["bundle", "validate", "--path", str(path), "--offline"]) + assert result.exit_code == 0, result.output + assert "first-child@1.0.0" in result.output + assert "second-child@1.0.0" in result.output + assert " ".join(result.output.split()).count("was not expanded") == 2 + + +def _write_reference_bundle(project: Path, references, *, bundle_id="parent", provides=None): + data = reference_manifest_dict(references, bundle_id=bundle_id) + if provides is not None: + data["provides"].update(provides) + path = project / f"{bundle_id}.yml" + path.write_text(yaml.safe_dump(data), encoding="utf-8") + return path + + +def _selected(bundle_id, *, install_allowed=True, source="default"): + return SimpleNamespace(entry=SimpleNamespace(id=bundle_id, version="1.0.0"), source=SimpleNamespace(id=source), install_allowed=install_allowed) + + +def test_validate_online_success(project: Path, monkeypatch): + path = _write_reference_bundle(project, [{"id": "child", "version": "1.0.0"}]) + child = BundleManifest.from_dict(valid_manifest_dict(bundle={"id": "child", "name": "Child", "version": "1.0.0", "role": "developer", "description": "child", "author": "tests", "license": "MIT"}, provides={})) + class Stack: + def resolve(self, bundle_id, version=None): return _selected(bundle_id) + monkeypatch.setattr("specify_cli.bundles._commands._build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr("specify_cli.bundles.command_validate._download_manifest", lambda *a, **k: child) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 0, result.output + + +def test_validate_rejects_reference_source_mismatch(project: Path, monkeypatch): + path = _write_reference_bundle( + project, + [{"id": "child", "version": "1.0.0", "source": "X"}], + ) + child = BundleManifest.from_dict( + valid_manifest_dict( + bundle={ + "id": "child", "name": "Child", "version": "1.0.0", + "role": "developer", "description": "child", "author": "tests", + "license": "MIT", + }, + provides={}, + ) + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return _selected(bundle_id, source="winner") + + monkeypatch.setattr("specify_cli.bundles._commands._build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr( + "specify_cli.bundles.command_validate._download_manifest", + lambda *a, **k: child, + ) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + + assert result.exit_code == 1, result.output + assert "requires source" in result.output + + +def test_validate_cycle_and_discovery_only_errors(project: Path, monkeypatch): + path = _write_reference_bundle(project, [{"id": "child", "version": "1.0.0"}]) + child_data = reference_manifest_dict([{"id": "parent", "version": "1.0.0"}], bundle_id="child") + child_data["bundle"]["version"] = "1.0.0" + child = BundleManifest.from_dict(child_data) + class Stack: + def resolve(self, bundle_id, version=None): return _selected(bundle_id, install_allowed=False, source="community") + monkeypatch.setattr("specify_cli.bundles._commands._build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr("specify_cli.bundles.command_validate._download_manifest", lambda *a, **k: child) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 1, result.output + assert "Bundle reference cycle" in result.output + + child = BundleManifest.from_dict(valid_manifest_dict(bundle={"id": "child", "name": "Child", "version": "1.0.0", "role": "developer", "description": "child", "author": "tests", "license": "MIT"}, provides={})) + monkeypatch.setattr("specify_cli.bundles.command_validate._download_manifest", lambda *a, **k: child) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 1, result.output + assert "discovery-only" in result.output + + +def test_validate_structural_error_skips_expansion(project: Path, monkeypatch): + path = _write_reference_bundle(project, [{"id": "child", "version": "1.0.0"}]) + data = yaml.safe_load(path.read_text(encoding="utf-8")) + data["schema_version"] = "1.0" + path.write_text(yaml.safe_dump(data), encoding="utf-8") + monkeypatch.setattr("specify_cli.bundles.command_validate._expand_manifest", lambda *a, **k: pytest.fail("expanded structural error")) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 1 + assert "Bundle references require schema_version" in result.output + + +def test_validate_expansion_failure_reports_own_error_once(project: Path, monkeypatch): + path = _write_reference_bundle(project, [{"id": "child", "version": "1.0.0"}], provides={"extensions": [{"id": "own", "version": "1.0.0"}]}) + monkeypatch.setattr("specify_cli.bundles.command_validate._expand_manifest", lambda *a, **k: (_ for _ in ()).throw(BundlerError("expansion boom"))) + monkeypatch.setattr("specify_cli.bundles.references.make_reference_checker", lambda *a, **k: lambda component: "own problem" if component.id == "own" else None) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 1 + assert result.output.count("expansion boom") == 1 + assert result.output.count("own problem") == 1 + assert result.output.index("expansion boom") < result.output.index("own problem") + + +def test_validate_flat_manifest_never_expands(project: Path, monkeypatch): + path = project / "flat.yml" + path.write_text(yaml.safe_dump(valid_manifest_dict(provides={})), encoding="utf-8") + monkeypatch.setattr("specify_cli.bundles.command_validate._expand_manifest", lambda *a, **k: pytest.fail("flat manifest expanded")) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path), "--offline"]) + assert result.exit_code == 0, result.output + + +def test_validate_real_catalog_with_patched_download(project: Path, monkeypatch): + path = _write_reference_bundle(project, [{"id": "child", "version": "1.0.0"}]) + child_path = project / "child.yml" + child_path.write_text(yaml.safe_dump(valid_manifest_dict(bundle={"id": "child", "name": "Child", "version": "1.0.0", "role": "developer", "description": "child", "author": "tests", "license": "MIT"}, provides={})), encoding="utf-8") + catalog = project / "catalog.json" + write_catalog_file(catalog, {"child": catalog_entry_dict("child", version="1.0.0")}) + (project / ".specify" / "bundle-catalogs.yml").write_text(yaml.safe_dump({"schema_version": "1.0", "catalogs": [{"id": "file", "url": str(catalog), "priority": 0, "install_policy": "install-allowed"}]}), encoding="utf-8") + monkeypatch.setattr("specify_cli.bundles.command_validate._download_manifest", lambda *a, **k: BundleManifest.from_file(child_path)) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 0, result.output + + +def test_validate_reports_sibling_conflict(project: Path, monkeypatch): + path = _write_reference_bundle( + project, + [{"id": "first", "version": "1.0.0"}, {"id": "second", "version": "1.0.0"}], + ) + children = { + "first": BundleManifest.from_dict(valid_manifest_dict( + bundle={"id": "first", "name": "First", "version": "1.0.0", "role": "developer", "description": "first", "author": "tests", "license": "MIT"}, + provides={"extensions": [{"id": "shared", "version": "1.0.0"}]}, + )), + "second": BundleManifest.from_dict(valid_manifest_dict( + bundle={"id": "second", "name": "Second", "version": "1.0.0", "role": "developer", "description": "second", "author": "tests", "license": "MIT"}, + provides={"extensions": [{"id": "shared", "version": "2.0.0"}]}, + )), + } + + class Stack: + def resolve(self, bundle_id, version=None): + return _selected(bundle_id) + + monkeypatch.setattr("specify_cli.bundles._commands._build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr( + "specify_cli.bundles.command_validate._download_manifest", + lambda resolved, *, offline: children[resolved.entry.id], + ) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 1, result.output + assert "declare 'shared'" in result.output + + +def test_validate_reports_override_warning(project: Path, monkeypatch): + path = _write_reference_bundle( + project, + [{"id": "child", "version": "1.0.0"}], + provides={"extensions": [{"id": "shared", "version": "2.0.0"}]}, + ) + child = BundleManifest.from_dict(valid_manifest_dict( + bundle={"id": "child", "name": "Child", "version": "1.0.0", "role": "developer", "description": "child", "author": "tests", "license": "MIT"}, + provides={"extensions": [{"id": "shared", "version": "1.0.0"}]}, + )) + class Stack: + def resolve(self, bundle_id, version=None): + return _selected(bundle_id) + + monkeypatch.setattr("specify_cli.bundles._commands._build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr("specify_cli.bundles.command_validate._download_manifest", lambda *a, **k: child) + monkeypatch.setattr("specify_cli.bundles.references.make_reference_checker", lambda *a, **k: lambda component: None) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 0, result.output + assert "overrides" in result.output diff --git a/tests/specify_cli/bundles/test_conflict.py b/tests/specify_cli/bundles/test_conflict.py index 5572f2b6e3..5d8e45aa9c 100644 --- a/tests/specify_cli/bundles/test_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -2,7 +2,7 @@ from __future__ import annotations from specify_cli.bundles._commands import _bundle_overlaps -from specify_cli.bundles.conflict import detect_conflicts +from specify_cli.bundles.conflict import component_setting_differences, detect_conflicts from specify_cli.bundles.manifest import BundleManifest, ComponentRef from specify_cli.bundles.records import InstalledBundleRecord, save_records from tests.specify_cli.bundles.helpers import valid_manifest_dict @@ -26,6 +26,74 @@ def test_matching_integration_no_clash(): assert report.has_blocking_conflict is False +def test_components_override_does_not_change_root_integration_clash(): + manifest = _manifest(integration={"id": "claude"}) + report = detect_conflicts( + manifest, + active_integration="copilot", + installed=[], + components=[ComponentRef(kind="extensions", id="expanded-only")], + ) + assert report.integration_clash is not None + + +def test_components_override_still_skips_owner(): + manifest = _manifest() + component = ComponentRef(kind="extensions", id="expanded-only") + owner = InstalledBundleRecord.create( + bundle_id="demo-bundle", version="1.0.0", components=[component] + ) + report = detect_conflicts( + manifest, active_integration="copilot", installed=[owner], components=[component] + ) + assert report.overlaps == [] + + +def test_components_override_is_used_for_overlap_detection(): + manifest = _manifest( + provides={"extensions": [{"id": "expanded-only", "version": "9.0.0"}]} + ) + expanded_component = ComponentRef( + kind="extensions", id="expanded-only", version="1.0.0" + ) + other = InstalledBundleRecord.create( + bundle_id="other", version="1.0.0", components=[expanded_component] + ) + report = detect_conflicts( + manifest, + active_integration="copilot", + installed=[other], + components=[expanded_component], + ) + assert report.overlaps == [ + "extension 'expanded-only' is already required by bundle 'other'." + ] + + +def test_expanded_component_version_clash_is_reported(): + manifest = _manifest() + expanded_component = ComponentRef( + kind="presets", id="expanded-only", version="2.0.0" + ) + other = InstalledBundleRecord.create( + bundle_id="other", + version="1.0.0", + components=[ + ComponentRef(kind="presets", id="expanded-only", version="3.0.0") + ], + ) + report = detect_conflicts( + manifest, + active_integration="copilot", + installed=[other], + components=[expanded_component], + ) + assert report.has_blocking_conflict + assert "expanded-only" in report.version_clashes[0] + assert "2.0.0" in report.version_clashes[0] + assert "3.0.0" in report.version_clashes[0] + + def test_agnostic_bundle_never_clashes(): manifest = _manifest() # no integration report = detect_conflicts(manifest, active_integration="copilot", installed=[]) @@ -108,3 +176,49 @@ def test_unknown_existing_pin_cannot_satisfy_an_exact_pin(): assert report.has_blocking_conflict assert "" in report.version_clashes[0] + + +def test_setting_differences_equal_settings_returns_empty(): + a = ComponentRef(kind="presets", id="p", source="cat", priority=5, strategy="replace") + b = ComponentRef(kind="presets", id="p", source="cat", priority=5, strategy="replace") + assert component_setting_differences(a, b) == [] + + +def test_setting_differences_ignores_versions(): + a = ComponentRef(kind="extensions", id="e", version="1.0.0") + b = ComponentRef(kind="extensions", id="e", version="2.0.0") + assert component_setting_differences(a, b) == [] + assert component_setting_differences( + a, ComponentRef(kind="extensions", id="e") + ) == [] + + +def test_setting_differences_ignores_preset_fields_for_non_presets(): + a = ComponentRef(kind="extensions", id="e", priority=1, strategy="append") + b = ComponentRef(kind="extensions", id="e", priority=2, strategy="replace") + assert component_setting_differences(a, b) == [] + + +def test_setting_differences_reports_source_for_any_kind(): + a = ComponentRef(kind="workflows", id="w", source="one") + b = ComponentRef(kind="workflows", id="w", source="two") + assert component_setting_differences(a, b) == ["source"] + + +def test_setting_differences_unset_source_differs_from_set_source(): + unset = ComponentRef(kind="extensions", id="e") + pinned = ComponentRef(kind="extensions", id="e", source="cat") + assert component_setting_differences(unset, pinned) == ["source"] + assert component_setting_differences(pinned, unset) == ["source"] + + +def test_setting_differences_preset_fields_in_stable_order(): + a = ComponentRef(kind="presets", id="p", source="one", priority=1, strategy="append") + b = ComponentRef(kind="presets", id="p", source="two", priority=2, strategy="replace") + assert component_setting_differences(a, b) == ["source", "priority", "strategy"] + assert component_setting_differences( + a, ComponentRef(kind="presets", id="p", source="one", priority=2, strategy="append") + ) == ["priority"] + assert component_setting_differences( + a, ComponentRef(kind="presets", id="p", source="one", priority=1, strategy="replace") + ) == ["strategy"] diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py new file mode 100644 index 0000000000..25153513fc --- /dev/null +++ b/tests/specify_cli/bundles/test_expansion.py @@ -0,0 +1,628 @@ +"""Deterministic tests for bundle-reference expansion.""" +from __future__ import annotations + +import pytest + +from specify_cli.bundles import BundlerError +import specify_cli.bundles.expansion as expansion_module +from specify_cli.bundles.expansion import ( + MAX_REFERENCED_BUNDLES, + ExpandedManifest, + LoadedBundle, + _component_differences, + expand_manifest, + format_chain, + require_installable, +) +from specify_cli.bundles.manifest import BundleManifest, ComponentRef +from tests.specify_cli.bundles.helpers import FakeBundleLoader, reference_manifest_dict + + +def manifest( + bundle_id: str, + *, + version: str = "1.0.0", + bundles: list[dict] | None = None, + **components, +) -> BundleManifest: + data = reference_manifest_dict( + bundles=bundles, + bundle_id=bundle_id, + **components, + ) + data["bundle"]["version"] = version + return BundleManifest.from_dict(data) + + +def loaded(*manifests: BundleManifest, source: str = "default", install_allowed: bool = True): + return { + (item.bundle.id, item.bundle.version): LoadedBundle(item, source, install_allowed) + for item in manifests + } + + +def test_flat_manifest_does_not_call_loader(): + root = BundleManifest.from_dict(reference_manifest_dict()) + loader = FakeBundleLoader({}) + expanded = expand_manifest(root, loader) + assert expanded == ExpandedManifest.flat(root) + assert loader.load_calls == [] + + +def test_direct_and_transitive_references_are_depth_first_and_provenanced(): + leaf = manifest("leaf", extensions=[{"id": "leaf-ext", "version": "1.0.0"}]) + child = manifest( + "child", + bundles=[{"id": "leaf", "version": "1.0.0"}], + extensions=[{"id": "child-ext", "version": "1.0.0"}], + ) + root = manifest( + "root", + bundles=[{"id": "child", "version": "1.0.0"}], + extensions=[{"id": "root-ext", "version": "1.0.0"}], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(leaf, child))) + assert [item.id for item in expanded.components] == ["leaf-ext", "child-ext", "root-ext"] + assert expanded.origins[("extensions", "leaf-ext")].origin == ("child", "leaf") + assert expanded.references[1].via == ("child",) + + +def test_reference_only_root_and_same_component_overlap(): + first = manifest("first", extensions=[{"id": "shared", "version": "v1.0.0"}]) + second = manifest("second", extensions=[{"id": "shared", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "first", "version": "1.0.0"}, + {"id": "second", "version": "1.0.0"}, + ], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(first, second))) + assert [item.id for item in expanded.components] == ["shared"] + assert expanded.origins[("extensions", "shared")].origin == ("first",) + + +def test_shared_bundle_is_loaded_and_expanded_once(monkeypatch): + shared = manifest("shared", extensions=[{"id": "shared-ext", "version": "1.0.0"}]) + left = manifest("left", bundles=[{"id": "shared", "version": "1.0.0"}]) + right = manifest("right", bundles=[{"id": "shared", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "left", "version": "1.0.0"}, + {"id": "right", "version": "1.0.0"}, + ], + ) + loader = FakeBundleLoader(loaded(shared, left, right)) + import specify_cli.bundles.expansion as expansion_module + + calls: list[str] = [] + original_expand = expansion_module._expand + + def counted_expand(manifest, chain, state): + calls.append(manifest.bundle.id) + return original_expand(manifest, chain, state) + + monkeypatch.setattr(expansion_module, "_expand", counted_expand) + expanded = expand_manifest(root, loader) + assert loader.load_calls.count(("shared", "1.0.0")) == 1 + assert calls.count("shared") == 1 + assert [item.id for item in expanded.components] == ["shared-ext"] + + +def test_layered_shared_graph_expands_each_bundle_once(monkeypatch): + leaf = manifest("leaf", extensions=[{"id": "leaf-ext", "version": "1.0.0"}]) + shared = manifest("shared", bundles=[{"id": "leaf", "version": "1.0.0"}]) + left = manifest("left", bundles=[{"id": "shared", "version": "1.0.0"}]) + right = manifest("right", bundles=[{"id": "shared", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "left", "version": "1.0.0"}, + {"id": "right", "version": "1.0.0"}, + ], + ) + loader = FakeBundleLoader(loaded(leaf, shared, left, right)) + calls: list[str] = [] + original_expand = expansion_module._expand + + def counted_expand(manifest, chain, state): + calls.append(manifest.bundle.id) + return original_expand(manifest, chain, state) + + monkeypatch.setattr(expansion_module, "_expand", counted_expand) + expand_manifest(root, loader) + assert len(calls) == len(set(calls)) + assert set(calls) == {"root", "left", "right", "shared", "leaf"} + + +def test_shorter_route_updates_override_and_preserves_cached_subplan(): + leaf = manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]) + long = manifest("long", bundles=[{"id": "leaf", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "long", "version": "1.0.0"}, + {"id": "leaf", "version": "1.0.0"}, + ], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + loader = FakeBundleLoader(loaded(leaf, long)) + state = expansion_module._ExpansionState(loader) + expansion_module._expand(long, ("root", "long"), state) + cached_subplans = dict(state.subplans) + root_plan = expansion_module._expand(root, ("root",), state) + assert all(state.subplans[key] == plan for key, plan in cached_subplans.items()) + expanded = ExpandedManifest( + root, + [entry.component for entry in root_plan.entries], + { + (entry.component.kind, entry.component.id): expansion_module.ComponentOrigin( + entry.origin, entry.overrides + ) + for entry in root_plan.entries + }, + state.references, + expansion_module._format_warnings(root, root_plan.notices), + ) + assert expanded.origins[("extensions", "e")].origin == () + assert next(ref for ref in expanded.references if ref.id == "leaf").via == () + assert len(expanded.warnings) == 1 + assert "root → leaf" in expanded.warnings[0] + assert "root → long → leaf" not in expanded.warnings[0] + + +def test_shared_dependency_override_is_merged_once(): + dependency = manifest("dependency", extensions=[{"id": "e", "version": "1.0.0"}]) + first = manifest("first", bundles=[{"id": "dependency", "version": "1.0.0"}]) + second = manifest("second", bundles=[{"id": "dependency", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "first", "version": "1.0.0"}, + {"id": "second", "version": "1.0.0"}, + ], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(dependency, first, second))) + origin = expanded.origins[("extensions", "e")] + assert len(expanded.warnings) == 1 + assert len(origin.overrides) == 1 + assert origin.overrides[0].origin == ("first", "dependency") + assert "root → first → dependency" in expanded.warnings[0] + assert "root → second → dependency" not in expanded.warnings[0] + + +def test_agreeing_copy_keeps_first_component_spelling_and_shorter_origin(): + dependency = manifest("dependency", extensions=[{"id": "e", "version": "v1.0.0"}]) + first = manifest("first", bundles=[{"id": "dependency", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "first", "version": "1.0.0"}, + {"id": "dependency", "version": "1.0.0"}, + ], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(dependency, first))) + assert expanded.components[0].version == "v1.0.0" + assert expanded.origins[("extensions", "e")].origin == ("dependency",) + + +def test_own_override_records_all_fields_and_own_position(): + child = manifest( + "child", + presets=[ + {"id": "style", "version": "1.0.0", "source": "a", "priority": 10, "strategy": "append"} + ], + ) + root = manifest( + "root", + bundles=[{"id": "child", "version": "1.0.0"}], + presets=[ + {"id": "style", "version": "2.0.0", "source": "b", "priority": 20, "strategy": "replace"} + ], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(child))) + origin = expanded.origins[("presets", "style")] + assert origin.origin == () + assert origin.overrides[0].origin == ("child",) + warning = expanded.warnings[0] + assert ( + "version 2.0.0 and source b and priority 20 and strategy replace (root) " + "overrides" + ) in warning + assert "version 1.0.0 from root → child" in warning + assert "source a from root → child" in warning + assert "priority 10 from root → child" in warning + assert "strategy append from root → child" in warning + assert expanded.warnings.count(warning) == 1 + + +def test_own_override_and_agreeing_copy_keep_own_positions(): + child = manifest( + "child", + extensions=[{"id": "a", "version": "1.0.0"}, {"id": "b", "version": "1.0.0"}], + ) + root = manifest( + "root", + bundles=[{"id": "child", "version": "1.0.0"}], + extensions=[ + {"id": "c", "version": "1.0.0"}, + {"id": "a", "version": "2.0.0"}, + ], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(child))) + assert [component.id for component in expanded.components] == ["b", "c", "a"] + + agreeing_child = manifest( + "agreeing-child", + extensions=[{"id": "a", "version": "1.0.0"}, {"id": "b", "version": "1.0.0"}], + ) + agreeing_root = manifest( + "agreeing-root", + bundles=[{"id": "agreeing-child", "version": "1.0.0"}], + extensions=[{"id": "c", "version": "1.0.0"}, {"id": "a", "version": "1.0.0"}], + ) + agreeing = expand_manifest(agreeing_root, FakeBundleLoader(loaded(agreeing_child))) + assert [component.id for component in agreeing.components] == ["b", "c", "a"] + assert agreeing.warnings == [] + + +def test_nested_override_is_resolved_before_parent_merge(): + leaf = manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]) + child = manifest( + "child", + bundles=[{"id": "leaf", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + root = manifest("root", bundles=[{"id": "child", "version": "1.0.0"}]) + expanded = expand_manifest(root, FakeBundleLoader(loaded(leaf, child))) + assert expanded.components[0].version == "2.0.0" + assert expanded.origins[("extensions", "e")].origin == ("child",) + assert "root → child → leaf" in expanded.warnings[0] + + +@pytest.mark.parametrize( + ("kind", "first_fields", "second_fields", "field", "first_value", "second_value"), + [ + ("extensions", {"version": "1.0.0"}, {"version": "2.0.0"}, "version", "1.0.0", "2.0.0"), + ("extensions", {"version": "1.0.0"}, {"version": None}, "version", "1.0.0", ""), + ("extensions", {"source": "a"}, {"source": "b"}, "source", "a", "b"), + ("extensions", {"source": None}, {"source": "a"}, "source", "", "a"), + ("presets", {"priority": 1}, {"priority": 2}, "priority", "1", "2"), + ("presets", {"strategy": "append"}, {"strategy": "replace"}, "strategy", "append", "replace"), + ], +) +def test_sibling_conflicts_include_field_values_and_chains( + kind, first_fields, second_fields, field, first_value, second_value +): + first = manifest("first", **{kind: [{"id": "e", **first_fields}]}) + second = manifest("second", **{kind: [{"id": "e", **second_fields}]}) + root = manifest( + "root", + bundles=[ + {"id": "first", "version": "1.0.0"}, + {"id": "second", "version": "1.0.0"}, + ], + ) + with pytest.raises(BundlerError, match=field) as caught: + expand_manifest(root, FakeBundleLoader(loaded(first, second))) + message = str(caught.value) + assert f"{field} {first_value} (root → first)" in message + assert f"{field} {second_value} (root → second)" in message + assert "declare 'e' in 'root'" in message + + +def test_multifield_preset_conflict_and_unset_source_are_reported(): + first = manifest("first", presets=[{"id": "p", "version": "1.0.0", "source": "a", "priority": 1, "strategy": "append"}]) + second = manifest("second", presets=[{"id": "p", "version": "1.0.0", "priority": 2, "strategy": "replace"}]) + root = manifest("root", bundles=[{"id": "first", "version": "1.0.0"}, {"id": "second", "version": "1.0.0"}]) + with pytest.raises(BundlerError) as caught: + expand_manifest(root, FakeBundleLoader(loaded(first, second))) + message = str(caught.value) + assert "source a; priority 1; strategy append (root → first)" in message + assert "source ; priority 2; strategy replace (root → second)" in message + + +@pytest.mark.parametrize( + ("root_id", "root_refs", "definitions", "chain"), + [ + ( + "root", + [{"id": "a", "version": "1.0.0"}], + [manifest("a", bundles=[{"id": "a", "version": "1.0.0"}])], + "root → a → a", + ), + ( + "root", + [{"id": "a", "version": "1.0.0"}], + [ + manifest("a", bundles=[{"id": "b", "version": "1.0.0"}]), + manifest("b", bundles=[{"id": "a", "version": "1.0.0"}]), + ], + "root → a → b → a", + ), + ( + "root", + [{"id": "a", "version": "1.0.0"}], + [ + manifest("a", bundles=[{"id": "root", "version": "1.0.0"}]), + manifest("root"), + ], + "root → a → root", + ), + ], +) +def test_cycles_are_rejected_with_full_chain(root_id, root_refs, definitions, chain): + root = manifest(root_id, bundles=root_refs) + with pytest.raises(BundlerError, match="cycle") as caught: + expand_manifest(root, FakeBundleLoader(loaded(*definitions))) + assert chain in str(caught.value) + + +def test_version_conflict_names_both_chains_and_matching_spellings_are_accepted(): + first = manifest("first", bundles=[{"id": "leaf", "version": "1.0.0"}]) + second = manifest("second", bundles=[{"id": "leaf", "version": "2.0.0"}]) + leaf1 = manifest("leaf", version="1.0.0") + root = manifest( + "root", + bundles=[ + {"id": "first", "version": "1.0.0"}, + {"id": "second", "version": "1.0.0"}, + ], + ) + with pytest.raises(BundlerError, match="versions") as caught: + expand_manifest(root, FakeBundleLoader(loaded(first, second, leaf1))) + message = str(caught.value) + assert "1.0.0 (root → first)" in message + assert "2.0.0 (root → second)" in message + + accepted_first = manifest("accepted-first", bundles=[{"id": "leaf", "version": "v1.0.0"}]) + accepted_second = manifest("accepted-second", bundles=[{"id": "leaf", "version": "1.0.0"}]) + accepted_root = manifest( + "accepted-root", + bundles=[ + {"id": "accepted-first", "version": "1.0.0"}, + {"id": "accepted-second", "version": "1.0.0"}, + ], + ) + accepted_leaf = manifest("leaf", version="1.0.0") + accepted_bundles = loaded(accepted_first, accepted_second, accepted_leaf) + accepted_bundles[("leaf", "v1.0.0")] = LoadedBundle(accepted_leaf, "default", True) + accepted_loader = FakeBundleLoader(accepted_bundles) + accepted = expand_manifest(accepted_root, accepted_loader) + assert [reference.id for reference in accepted.references] == [ + "accepted-first", "leaf", "accepted-second" + ] + leaf_reference = next( + reference for reference in accepted.references if reference.id == "leaf" + ) + assert leaf_reference.version == "v1.0.0" + assert leaf_reference.via == ("accepted-first",) + assert accepted_loader.load_calls.count(("leaf", "v1.0.0")) == 1 + + +def test_depth_limit_accepts_ten_levels_and_rejects_eleven(): + bundles = [] + for index in range(10): + bundles.append( + manifest( + f"b{index}", + bundles=[] + if index == 9 + else [{"id": f"b{index + 1}", "version": "1.0.0"}], + ) + ) + root = manifest("root", bundles=[{"id": "b0", "version": "1.0.0"}]) + expanded = expand_manifest(root, FakeBundleLoader(loaded(*bundles))) + assert [reference.id for reference in expanded.references] == [f"b{i}" for i in range(10)] + + too_deep = manifest("b10") + bundles.append(too_deep) + bundles[9] = manifest("b9", bundles=[{"id": "b10", "version": "1.0.0"}]) + with pytest.raises(BundlerError, match="depth exceeds 10") as caught: + expand_manifest(root, FakeBundleLoader(loaded(*bundles))) + assert ( + "root → b0 → b1 → b2 → b3 → b4 → b5 → b6 → b7 → b8 → b9 → b10" + in str(caught.value) + ) + + +def test_cached_subtree_rechecks_depth_for_a_deeper_route(): + leaf = manifest("leaf") + cached = manifest("cached", bundles=[{"id": "leaf", "version": "1.0.0"}]) + parent = manifest("parent", bundles=[{"id": "cached", "version": "1.0.0"}]) + # The route through parent is intentionally lengthened after cached has + # already been completed through the direct route. + current = parent + definitions = [leaf, cached, parent] + for index in range(8): + current = manifest( + f"deep{index}", + bundles=[{"id": current.bundle.id, "version": "1.0.0"}], + ) + definitions.append(current) + root = manifest( + "root", + bundles=[ + {"id": "cached", "version": "1.0.0"}, + {"id": current.bundle.id, "version": "1.0.0"}, + ], + ) + with pytest.raises(BundlerError, match="depth exceeds 10") as caught: + expand_manifest(root, FakeBundleLoader(loaded(*definitions))) + assert ( + "root → deep7 → deep6 → deep5 → deep4 → deep3 → deep2 → deep1 → " + "deep0 → parent → cached → leaf" + ) in str(caught.value) + + current = parent + definitions = [leaf, cached, parent] + for index in range(7): + current = manifest( + f"deep{index}", + bundles=[{"id": current.bundle.id, "version": "1.0.0"}], + ) + definitions.append(current) + root = manifest( + "root", + bundles=[ + {"id": "cached", "version": "1.0.0"}, + {"id": current.bundle.id, "version": "1.0.0"}, + ], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(*definitions))) + assert [reference.id for reference in expanded.references] == [ + "cached", "leaf", "deep6", "deep5", "deep4", "deep3", "deep2", "deep1", "deep0", "parent" + ] + + +def test_reference_limit_is_checked_before_loading(): + refs = [{"id": f"b{i}", "version": "1.0.0"} for i in range(MAX_REFERENCED_BUNDLES + 1)] + root = manifest("root", bundles=refs) + children = [manifest(f"b{i}") for i in range(MAX_REFERENCED_BUNDLES)] + loader = FakeBundleLoader(loaded(*children)) + with pytest.raises(BundlerError, match="50"): + expand_manifest(root, loader) + assert loader.load_calls == [(f"b{i}", "1.0.0") for i in range(MAX_REFERENCED_BUNDLES)] + + +@pytest.mark.parametrize("explicit_first", [False, True]) +def test_explicit_source_mismatch_is_rejected_in_both_traversal_orders(explicit_first): + child = manifest("child") + left_ref = {"id": "child", "version": "1.0.0"} + right_ref = {"id": "child", "version": "1.0.0", "source": "other"} + left = manifest("left", bundles=[left_ref]) + right = manifest("right", bundles=[right_ref]) + refs = [ + {"id": "right", "version": "1.0.0"}, + {"id": "left", "version": "1.0.0"}, + ] if explicit_first else [ + {"id": "left", "version": "1.0.0"}, + {"id": "right", "version": "1.0.0"}, + ] + root = manifest("root", bundles=refs) + loader = FakeBundleLoader(loaded(child, left, right)) + with pytest.raises(BundlerError, match="requires source"): + expand_manifest(root, loader) + if explicit_first: + assert loader.load_calls[:2] == [("right", "1.0.0"), ("child", "1.0.0")] + + +@pytest.mark.parametrize("left_explicit", [False, True]) +def test_omitted_and_matching_explicit_sources_reuse_cache(left_explicit): + child = manifest("child") + left = manifest( + "left", + bundles=[ + {"id": "child", "version": "1.0.0", **({"source": "default"} if left_explicit else {})} + ], + ) + right = manifest( + "right", + bundles=[ + {"id": "child", "version": "1.0.0", **({} if left_explicit else {"source": "default"})} + ], + ) + root = manifest( + "root", + bundles=[ + {"id": "left", "version": "1.0.0"}, + {"id": "right", "version": "1.0.0"}, + ], + ) + loader = FakeBundleLoader(loaded(child, left, right)) + expanded = expand_manifest(root, loader) + assert [ref.id for ref in expanded.references].count("child") == 1 + assert loader.load_calls.count(("child", "1.0.0")) == 1 + + +def test_loaded_manifest_identity_and_version_are_verified(): + root = manifest("root", bundles=[{"id": "child", "version": "1.0.0"}]) + wrong_id = manifest("other") + with pytest.raises(BundlerError, match="loaded manifest"): + expand_manifest(root, FakeBundleLoader({("child", "1.0.0"): LoadedBundle(wrong_id, "default", True)})) + wrong_version = manifest("child", version="2.0.0") + with pytest.raises(BundlerError, match="loaded manifest version"): + expand_manifest(root, FakeBundleLoader({("child", "1.0.0"): LoadedBundle(wrong_version, "default", True)})) + + +def test_kind_order_is_stable_after_expansion(): + child = manifest( + "child", + workflows=[{"id": "w", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "1.0.0"}], + ) + root = manifest( + "root", + bundles=[{"id": "child", "version": "1.0.0"}], + steps=[{"id": "s"}], + presets=[{"id": "p", "version": "1.0.0", "priority": 1, "strategy": "append"}], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(child))) + assert [component.kind for component in expanded.components] == [ + "extensions", "presets", "steps", "workflows" + ] + + +def test_source_mismatch_on_cache_hit_and_loader_error_context(): + child = manifest("child") + left = manifest("left", bundles=[{"id": "child", "version": "1.0.0"}]) + right = manifest( + "right", + bundles=[{"id": "child", "version": "1.0.0", "source": "other"}], + ) + root = manifest( + "root", + bundles=[ + {"id": "left", "version": "1.0.0"}, + {"id": "right", "version": "1.0.0"}, + ], + ) + with pytest.raises(BundlerError) as excinfo: + expand_manifest(root, FakeBundleLoader(loaded(child, left, right))) + message = str(excinfo.value) + assert message.count("Bundle reference") == 1 + assert "winning catalog is 'default'" in message + assert "loaded bundle source" not in message + + missing = manifest("root", bundles=[{"id": "missing", "version": "1.0.0"}]) + with pytest.raises(BundlerError, match=r"Cannot resolve bundle reference missing@1.0.0 \(root\)"): + expand_manifest(missing, FakeBundleLoader({})) + + nested = manifest("nested", bundles=[{"id": "missing", "version": "1.0.0"}]) + root = manifest("root", bundles=[{"id": "nested", "version": "1.0.0"}]) + with pytest.raises( + BundlerError, + match=r"Cannot resolve bundle reference missing@1.0.0 \(root → nested\)", + ): + expand_manifest(root, FakeBundleLoader(loaded(nested))) + + +def test_loader_programming_errors_are_not_rewritten_as_resolution_errors(): + root = manifest("root", bundles=[{"id": "child", "version": "1.0.0"}]) + + class BrokenLoader: + def load(self, ref, chain): + raise TypeError("loader contract violated") + + with pytest.raises(TypeError, match="loader contract violated"): + expand_manifest(root, BrokenLoader()) + + +def test_require_installable_and_formatting(): + child = manifest("community") + root = manifest("root", bundles=[{"id": "community", "version": "1.0.0"}]) + expanded = expand_manifest(root, FakeBundleLoader(loaded(child, install_allowed=False))) + with pytest.raises(BundlerError, match="discovery-only"): + require_installable(expanded) + assert format_chain(("root", "community")) == "root → community" + + +def test_component_difference_uses_strict_pinned_rules(): + assert _component_differences( + ComponentRef("steps", "lint", None), ComponentRef("steps", "lint", "1.0.0") + ) == ["version"] + assert _component_differences( + ComponentRef("extensions", "e", "v1.0.0"), ComponentRef("extensions", "e", "1.0.0") + ) == [] diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index 7830f1eda6..473f044492 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -10,8 +10,9 @@ import pytest from specify_cli.bundler import BundlerError +from specify_cli.bundles.expansion import LoadedBundle, expand_manifest from specify_cli.bundles.installer import install_bundle, remove_bundle -from specify_cli.bundles.manifest import BundleManifest, ComponentRef +from specify_cli.bundles.manifest import BundleManifest, BundleRef, ComponentRef from specify_cli.bundles.records import ( InstalledBundleRecord, load_records, @@ -20,8 +21,10 @@ ) from specify_cli.bundles.resolver import resolve_install_plan from tests.specify_cli.bundles.helpers import ( + FakeBundleLoader, FakeInstaller, make_project, + reference_manifest_dict, valid_manifest_dict, ) @@ -32,6 +35,26 @@ def _plan(manifest): ) +def _expanded_plan(root_id: str, child: BundleManifest): + root = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": child.bundle.id, "version": child.bundle.version}], + bundle_id=root_id, + ) + ) + expanded = expand_manifest( + root, + FakeBundleLoader({ + (child.bundle.id, child.bundle.version): LoadedBundle( + child, "default", True + ) + }), + ) + return root, resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + + @pytest.mark.parametrize("action", ["install", "reuse", "refresh"]) def test_install_returns_structured_patch_warnings_without_printing( tmp_path: Path, capsys, action: str, @@ -90,6 +113,131 @@ def test_install_records_and_invokes_primitives(tmp_path: Path): assert records[0].bundle_id == "demo-bundle" +def test_install_records_expanded_components_and_bundle_references(tmp_path: Path): + make_project(tmp_path) + child = _bundle("child", ["ext-a"]) + root, plan = _expanded_plan("team", child) + installer = FakeInstaller() + + install_bundle(tmp_path, plan, installer, manifest=root) + + record = load_records(tmp_path)[0] + assert record.required_components == tuple(plan.components) + assert record.contributed_components == tuple(plan.components) + assert record.bundle_references == (BundleRef("child", "1.0.0", "default"),) + + +def test_refresh_drops_components_removed_from_expanded_plan(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first_root, first_plan = _expanded_plan( + "team", _bundle("child", ["ext-a", "ext-b"]) + ) + install_bundle(tmp_path, first_plan, installer, manifest=first_root) + + second_root, second_plan = _expanded_plan( + "team", _bundle("child", ["ext-a"]) + ) + result = install_bundle( + tmp_path, second_plan, installer, manifest=second_root, refresh=True + ) + + assert ("extensions", "ext-b") in { + (component.kind, component.id) for component in result.uninstalled + } + assert ("extensions", "ext-b") not in installer.installed + assert load_records(tmp_path)[0].required_components == tuple(second_plan.components) + assert load_records(tmp_path)[0].bundle_references == ( + BundleRef("child", "1.0.0", "default"), + ) + + flat = _bundle("team", ["ext-a"]) + install_bundle(tmp_path, _plan(flat), installer, manifest=flat, refresh=True) + assert load_records(tmp_path)[0].bundle_references == () + + +def test_refresh_with_bumped_reference_version_updates_record(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first_root, first_plan = _expanded_plan("team", _bundle("child", ["ext-a"])) + install_bundle(tmp_path, first_plan, installer, manifest=first_root) + + child_v2 = _bundle("child", ["ext-a"], version="2.0.0") + second_root, second_plan = _expanded_plan("team", child_v2) + install_bundle(tmp_path, second_plan, installer, manifest=second_root, refresh=True) + + assert load_records(tmp_path)[0].bundle_references == ( + BundleRef("child", "2.0.0", "default"), + ) + + +def test_removal_keeps_expanded_component_required_by_standalone_bundle( + tmp_path: Path, +): + make_project(tmp_path) + installer = FakeInstaller() + standalone = _bundle("child", ["ext-a"]) + install_bundle(tmp_path, _plan(standalone), installer, manifest=standalone) + root, plan = _expanded_plan("team", standalone) + install_bundle(tmp_path, plan, installer, manifest=root) + + result = remove_bundle(tmp_path, "team", installer) + + assert ("extensions", "ext-a") in { + (component.kind, component.id) for component in result.skipped + } + assert ("extensions", "ext-a") in installer.installed + assert [record.bundle_id for record in load_records(tmp_path)] == ["child"] + + +def test_standalone_and_referencing_installs_share_expanded_components(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + child = _bundle("child", ["ext-a"]) + install_bundle(tmp_path, _plan(child), installer, manifest=child) + root, plan = _expanded_plan("team", child) + + result = install_bundle(tmp_path, plan, installer, manifest=root) + + assert result.skipped == plan.components + records = {record.bundle_id: record for record in load_records(tmp_path)} + assert records["team"].contributed_components == tuple(plan.components) + assert installer.install_calls == [("extensions", "ext-a")] + + +def test_removing_standalone_child_preserves_team_references_and_contributions( + tmp_path: Path, +): + make_project(tmp_path) + installer = FakeInstaller() + child = _bundle("child", ["ext-a"]) + install_bundle(tmp_path, _plan(child), installer, manifest=child) + root, plan = _expanded_plan("team", child) + install_bundle(tmp_path, plan, installer, manifest=root) + + remove_bundle(tmp_path, "child", installer) + + records = {record.bundle_id: record for record in load_records(tmp_path)} + assert records["team"].bundle_references == ( + BundleRef("child", "1.0.0", "default"), + ) + assert records["team"].contributed_components == tuple(plan.components) + assert ("extensions", "ext-a") in installer.installed + + +def test_conflict_from_expanded_component_blocks_install(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + installed = _bundle("installed", ["ext-a"], version="1.0.0") + install_bundle(tmp_path, _plan(installed), installer, manifest=installed) + root, plan = _expanded_plan("team", _bundle("child", ["ext-a"], version="2.0.0")) + + with pytest.raises(BundlerError, match="ext-a"): + install_bundle(tmp_path, plan, installer, manifest=root) + + assert [record.bundle_id for record in load_records(tmp_path)] == ["installed"] + + def test_install_is_idempotent(tmp_path: Path): make_project(tmp_path) manifest = BundleManifest.from_dict(valid_manifest_dict()) diff --git a/tests/specify_cli/bundles/test_manifest.py b/tests/specify_cli/bundles/test_manifest.py new file mode 100644 index 0000000000..8306e8fc1c --- /dev/null +++ b/tests/specify_cli/bundles/test_manifest.py @@ -0,0 +1,221 @@ +"""Unit tests for ``specify_cli.bundles.manifest`` bundle references.""" +from __future__ import annotations + +import pytest + +from specify_cli.bundles import BundlerError +from specify_cli.bundles.manifest import ( + SUPPORTED_SCHEMA_VERSIONS, + BundleManifest, + BundleRef, +) +from tests.specify_cli.bundles.helpers import ( + reference_manifest_dict, + valid_manifest_dict, +) + + +def _manifest(data: dict) -> BundleManifest: + return BundleManifest.from_dict(data) + + +def _errors(data: dict) -> list[str]: + return _manifest(data).structural_errors() + + +def test_supported_schema_versions_include_reference_schema(): + assert SUPPORTED_SCHEMA_VERSIONS == {"1.0", "1.1"} + + +def test_bundle_ref_label_pinned_and_unpinned(): + assert BundleRef(id="bugfix", version="1.0.1").label() == "bundle:bugfix@1.0.1" + assert BundleRef(id="bugfix").label() == "bundle:bugfix@unpinned" + + +def test_provides_bundles_parsed_in_declaration_order(): + manifest = _manifest( + reference_manifest_dict( + [ + {"id": "bugfix", "version": "1.0.1", "source": "default"}, + {"id": "qa-pack", "version": "2.0.0"}, + ] + ) + ) + + assert manifest.bundles == [ + BundleRef(id="bugfix", version="1.0.1", source="default"), + BundleRef(id="qa-pack", version="2.0.0", source=None), + ] + assert manifest.structural_errors() == [] + + +def test_reference_values_are_stripped_and_version_coerced_to_text(): + manifest = _manifest( + reference_manifest_dict( + [{"id": " bugfix ", "version": " 1.0.1 ", "source": " default "}] + ) + ) + + assert manifest.bundles == [BundleRef("bugfix", "1.0.1", "default")] + + +def test_components_stay_own_only_when_bundles_declared(): + manifest = _manifest( + reference_manifest_dict( + [{"id": "bugfix", "version": "1.0.1"}], + extensions=[{"id": "secure-design", "version": "0.5.0"}], + ) + ) + + assert [c.id for c in manifest.components] == ["secure-design"] + assert manifest.structural_errors() == [] + + +def test_reference_only_bundle_is_valid(): + manifest = _manifest( + reference_manifest_dict([{"id": "bugfix", "version": "1.0.1"}]) + ) + + assert manifest.components == [] + assert manifest.structural_errors() == [] + + +@pytest.mark.parametrize("schema_version", ["1.0", "1.1"]) +@pytest.mark.parametrize("declaration", ["absent", "null", "empty"]) +def test_absent_empty_or_null_references_accepted_under_supported_schemas( + schema_version, declaration +): + data = valid_manifest_dict(schema_version=schema_version) + if declaration == "null": + data["provides"]["bundles"] = None + elif declaration == "empty": + data["provides"]["bundles"] = [] + + manifest = _manifest(data) + + assert manifest.bundles == [] + assert manifest.structural_errors() == [] + + +def test_schema_1_1_without_references_is_valid(): + manifest = _manifest(valid_manifest_dict(schema_version="1.1")) + + assert manifest.bundles == [] + assert manifest.structural_errors() == [] + + +def test_references_with_schema_1_0_rejected(): + errors = _errors( + reference_manifest_dict( + [{"id": "bugfix", "version": "1.0.1"}], schema_version="1.0" + ) + ) + + assert errors == ['Bundle references require schema_version "1.1".'] + + +def test_references_with_missing_schema_rejected(): + data = reference_manifest_dict([{"id": "bugfix", "version": "1.0.1"}]) + del data["schema_version"] + + errors = _errors(data) + + assert any('require schema_version "1.1"' in e for e in errors) + assert any("not supported" in e for e in errors) + + +def test_unsupported_schema_rejected(): + errors = _errors(valid_manifest_dict(schema_version="2.0")) + + assert any("schema_version '2.0' is not supported" in e for e in errors) + + +def test_reference_missing_id_rejected(): + errors = _errors(reference_manifest_dict([{"version": "1.0.1"}])) + + assert errors == ["A bundle reference is missing its 'id'."] + + +@pytest.mark.parametrize("bad_id", ["../evil", "a/b", "Upper", "-lead", "trail-"]) +def test_reference_unsafe_id_rejected(bad_id): + errors = _errors(reference_manifest_dict([{"id": bad_id, "version": "1.0.1"}])) + + assert any( + f"Bundle reference id '{bad_id}' must be a slug" in e for e in errors + ) + + +def test_reference_missing_version_rejected(): + errors = _errors(reference_manifest_dict([{"id": "bugfix"}])) + + assert errors == ["bundle reference 'bugfix' must be pinned to a 'version'."] + + +@pytest.mark.parametrize("bad_version", ["not-semver", ">=1.0.0", "1.0"]) +def test_reference_invalid_version_rejected(bad_version): + errors = _errors( + reference_manifest_dict([{"id": "bugfix", "version": bad_version}]) + ) + + assert errors == [ + f"bundle reference 'bugfix' has invalid version '{bad_version}'." + ] + + +def test_reference_version_with_v_prefix_accepted(): + errors = _errors(reference_manifest_dict([{"id": "bugfix", "version": "v1.0.1"}])) + + assert errors == [] + + +def test_duplicate_reference_rejected_even_at_different_versions(): + errors = _errors( + reference_manifest_dict( + [ + {"id": "bugfix", "version": "1.0.1"}, + {"id": "bugfix", "version": "1.0.2"}, + ] + ) + ) + + assert any( + "Duplicate bundle reference 'bugfix'" in e + and "declare each bundle reference only once" in e + for e in errors + ) + + +def test_self_reference_rejected(): + errors = _errors( + reference_manifest_dict( + [{"id": "team", "version": "1.0.0"}], bundle_id="team" + ) + ) + + assert errors == [ + "Bundle reference 'team' is invalid: a bundle cannot reference itself." + ] + + +def test_reference_to_other_bundle_is_not_self_reference(): + errors = _errors( + reference_manifest_dict( + [{"id": "bugfix", "version": "1.0.1"}], bundle_id="team" + ) + ) + + assert errors == [] + + +@pytest.mark.parametrize( + "bundles", ["bugfix", {"id": "bugfix", "version": "1.0.1"}, 5] +) +def test_non_list_bundles_rejected(bundles): + with pytest.raises(BundlerError, match=r"provides\.bundles must be a list"): + _manifest(reference_manifest_dict(bundles)) + + +@pytest.mark.parametrize("entry", ["bugfix", ["bugfix"], 1, None]) +def test_non_mapping_bundle_entry_rejected(entry): + with pytest.raises(BundlerError, match=r"provides\.bundles entry must be a mapping"): + _manifest(reference_manifest_dict([entry])) diff --git a/tests/specify_cli/bundles/test_packager.py b/tests/specify_cli/bundles/test_packager.py index 0044e37830..8587535ee6 100644 --- a/tests/specify_cli/bundles/test_packager.py +++ b/tests/specify_cli/bundles/test_packager.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import socket import zipfile from pathlib import Path @@ -26,6 +27,41 @@ def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path: return directory +def test_reference_only_bundle_packages_without_expansion(tmp_path: Path, monkeypatch): + bundle = tmp_path / "reference-only" + data = valid_manifest_dict(schema_version="1.1") + data["provides"] = {"bundles": [{"id": "child", "version": "1.0.0"}]} + bundle.mkdir() + (bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (bundle / "README.md").write_text("# Parent", encoding="utf-8") + + monkeypatch.setattr( + "specify_cli.bundles.expansion.expand_manifest", + lambda *args, **kwargs: pytest.fail("build expanded bundle references"), + ) + monkeypatch.setattr( + socket, + "create_connection", + lambda *args, **kwargs: pytest.fail("build attempted network access"), + ) + result = build_bundle(bundle, output_dir=tmp_path / "out") + + assert result.artifact_path.exists() + + +def test_reference_structural_error_refuses_build(tmp_path: Path): + bundle = tmp_path / "invalid-reference" + data = valid_manifest_dict(schema_version="1.0") + data["provides"] = {"bundles": [{"id": "child", "version": "1.0.0"}]} + bundle.mkdir() + (bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (bundle / "README.md").write_text("# Parent", encoding="utf-8") + + with pytest.raises(BundlerError, match="Bundle references require schema_version"): + build_bundle(bundle, output_dir=tmp_path / "out") + assert not list((tmp_path / "out").glob("*.zip")) + + def test_artifact_named_by_id_and_version(tmp_path: Path): bundle = _make_bundle(tmp_path / "b") result = build_bundle(bundle, output_dir=tmp_path / "out") diff --git a/tests/specify_cli/bundles/test_records.py b/tests/specify_cli/bundles/test_records.py index 7de2a14508..4ac67b97b1 100644 --- a/tests/specify_cli/bundles/test_records.py +++ b/tests/specify_cli/bundles/test_records.py @@ -7,7 +7,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.manifest import BundleRef, ComponentRef from specify_cli.bundles.records import ( InstalledBundleRecord, components_still_needed, @@ -41,6 +41,95 @@ def test_save_and_load_roundtrip(tmp_path: Path): assert loaded[0].required_components == loaded[0].contributed_components +def test_roundtrip_preserves_bundle_references(tmp_path: Path): + (tmp_path / ".specify").mkdir() + references = [ + BundleRef("bugfix", "1.0.0", "default"), + BundleRef("qa-pack", "2.0.0"), + ] + record = InstalledBundleRecord.create( + bundle_id="team", + version="1.0.0", + components=[], + bundle_references=references, + ) + + save_records(tmp_path, [record]) + restored = load_records(tmp_path)[0] + + assert restored.bundle_references == tuple(references) + assert restored.to_dict()["bundle_references"] == [ + {"id": "bugfix", "version": "1.0.0", "source": "default"}, + {"id": "qa-pack", "version": "2.0.0", "source": None}, + ] + + +def test_empty_bundle_references_are_omitted_and_flat_records_stay_identical(): + record = _record("flat", [("extensions", "ext-a")]) + + assert record.bundle_references == () + assert "bundle_references" not in record.to_dict() + assert record.to_dict() == { + "bundle_id": "flat", + "version": "1.0.0", + "installed_at": record.installed_at, + "contributed_components": [{"kind": "extensions", "id": "ext-a"}], + "required_components": [{"kind": "extensions", "id": "ext-a"}], + } + + +@pytest.mark.parametrize("value", [None, []]) +def test_from_dict_accepts_empty_bundle_references(value): + data = { + "bundle_id": "team", + "version": "1.0.0", + "contributed_components": [], + "bundle_references": value, + } + assert InstalledBundleRecord.from_dict(data).bundle_references == () + data.pop("bundle_references") + assert InstalledBundleRecord.from_dict(data).bundle_references == () + + +@pytest.mark.parametrize( + "value", + [ + 0, + False, + "not-a-list", + {}, + [{"id": "bugfix"}], + [{"version": "1.0.0"}], + [{"id": "bugfix", "version": ""}], + [{"id": "bugfix", "version": 1}], + [{"id": 1, "version": "1.0.0"}], + [{"id": " ", "version": "1.0.0"}], + [{"id": "bugfix", "version": " "}], + [{"id": "bugfix", "version": "1.0.0", "source": 1}], + ["not-a-mapping"], + ], +) +def test_from_dict_rejects_corrupt_bundle_references(value): + data = { + "bundle_id": "team", + "version": "1.0.0", + "contributed_components": [], + "bundle_references": value, + } + with pytest.raises(BundlerError, match="Corrupt record: 'bundle_references'"): + InstalledBundleRecord.from_dict(data) + + +def test_empty_reference_source_normalizes_to_none(): + data = { + "bundle_id": "team", + "version": "1.0.0", + "contributed_components": [], + "bundle_references": [{"id": "child", "version": "1.0.0", "source": ""}], + } + assert InstalledBundleRecord.from_dict(data).bundle_references[0].source is None + + def test_roundtrip_retains_unowned_required_pin(tmp_path: Path): (tmp_path / ".specify").mkdir() required = ComponentRef( diff --git a/tests/specify_cli/bundles/test_resolver.py b/tests/specify_cli/bundles/test_resolver.py index a8c88c4179..e38d2e56d9 100644 --- a/tests/specify_cli/bundles/test_resolver.py +++ b/tests/specify_cli/bundles/test_resolver.py @@ -4,9 +4,14 @@ import pytest from specify_cli.bundler import BundlerError +from specify_cli.bundles.expansion import LoadedBundle, expand_manifest from specify_cli.bundles.manifest import BundleManifest from specify_cli.bundles.resolver import resolve_install_plan -from tests.specify_cli.bundles.helpers import valid_manifest_dict +from tests.specify_cli.bundles.helpers import ( + FakeBundleLoader, + reference_manifest_dict, + valid_manifest_dict, +) def _manifest(**overrides) -> BundleManifest: @@ -105,3 +110,226 @@ def test_tool_requirements_become_warnings(): manifest, speckit_version="0.11.2", active_integration="copilot" ) assert any("docker" in w for w in plan.warnings) + + +def _expanded_root( + *, + root_requires: dict | None = None, + root_integration: dict | None = None, + child_requires: dict | None = None, + child_integration: dict | None = None, + child_extensions: list[dict] | None = None, +): + root_data = reference_manifest_dict( + [{"id": "bugfix", "version": "1.0.0"}], bundle_id="team" + ) + root_data["requires"] = root_requires or {"speckit_version": ">=0.1.0"} + root_data["integration"] = root_integration + root_data["provides"]["extensions"] = [{"id": "root-ext", "version": "1.0.0"}] + root = BundleManifest.from_dict(root_data) + child_data = valid_manifest_dict( + requires=child_requires or {"speckit_version": ">=0.1.0"}, + integration=child_integration, + ) + child_data["bundle"]["id"] = "bugfix" + child_data["bundle"]["version"] = "1.0.0" + child_data["provides"] = { + "extensions": child_extensions or [{"id": "child-ext", "version": "1.0.0"}] + } + child = BundleManifest.from_dict(child_data) + loader = FakeBundleLoader( + {("bugfix", "1.0.0"): LoadedBundle(child, "default", True)} + ) + return root, expand_manifest(root, loader) + + +def test_plan_from_expanded_manifest_carries_references_and_components(): + root, expanded = _expanded_root() + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + assert plan.references == expanded.references + assert [component.id for component in plan.components] == [ + "child-ext", + "root-ext", + ] + assert plan.bundle_id == root.bundle.id + + +def test_reference_tools_and_mcp_are_union_deduplicated(): + _, expanded = _expanded_root( + root_requires={ + "speckit_version": ">=0.1.0", + "tools": ["docker", "git"], + "mcp": ["shared", "root-only"], + }, + child_requires={ + "speckit_version": ">=0.1.0", + "tools": ["git", "node"], + "mcp": ["shared", "child-only"], + }, + ) + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + assert plan.warnings[:2] == [ + "Requires external tools: docker, git, node", + "Requires MCP servers: shared, root-only, child-only", + ] + + +def test_reference_matching_integration_has_no_warning(): + _, expanded = _expanded_root(child_integration={"id": "copilot"}) + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + assert not any("targets" in warning for warning in plan.warnings) + + +def test_unexpanded_manifest_with_references_is_rejected(): + root = BundleManifest.from_dict( + reference_manifest_dict([{"id": "bugfix", "version": "1.0.0"}]) + ) + with pytest.raises(BundlerError, match="before expansion"): + resolve_install_plan( + root, speckit_version="0.11.2", active_integration="copilot" + ) + + +def test_reference_version_gate_reports_full_chain(): + _, expanded = _expanded_root( + child_requires={"speckit_version": ">=2.0"} + ) + with pytest.raises(BundlerError, match=r"Bundle 'bugfix' \(team → bugfix\)"): + resolve_install_plan( + expanded, speckit_version="1.3", active_integration="copilot" + ) + + +def test_reference_version_gate_can_be_disabled(): + _, expanded = _expanded_root(child_requires={"speckit_version": ">=99.0.0"}) + plan = resolve_install_plan( + expanded, + speckit_version="0.11.2", + active_integration="copilot", + enforce_version=False, + ) + assert plan.bundle_id == "team" + + +def test_transitive_reference_version_gate_reports_full_chain(): + root_data = reference_manifest_dict([{"id": "a", "version": "1.0.0"}], bundle_id="team") + root = BundleManifest.from_dict(root_data) + a_data = reference_manifest_dict([{"id": "bugfix", "version": "1.0.0"}], bundle_id="a") + a_data["bundle"]["version"] = "1.0.0" + a = BundleManifest.from_dict(a_data) + bugfix_data = valid_manifest_dict( + requires={"speckit_version": ">=99.0.0"}, + ) + bugfix_data["bundle"]["id"] = "bugfix" + bugfix_data["bundle"]["version"] = "1.0.0" + bugfix = BundleManifest.from_dict(bugfix_data) + loader = FakeBundleLoader({ + ("a", "1.0.0"): LoadedBundle(a, "default", True), + ("bugfix", "1.0.0"): LoadedBundle(bugfix, "default", True), + }) + expanded = expand_manifest(root, loader) + with pytest.raises( + BundlerError, + match=r"Bundle 'bugfix' \(team → a → bugfix\)", + ): + resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + + +def test_reference_integration_mismatch_is_a_warning(): + _, expanded = _expanded_root(child_integration={"id": "copilot"}) + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="claude" + ) + assert plan.warnings[-1] == ( + "Bundle 'bugfix' (team → bugfix) targets 'copilot'; " + "this install uses 'claude'." + ) + + +def test_root_integration_pin_is_used_for_reference_warning(): + _, expanded = _expanded_root( + root_integration={"id": "copilot"}, child_integration={"id": "claude"} + ) + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + assert plan.warnings[-1] == ( + "Bundle 'bugfix' (team → bugfix) targets 'claude'; " + "this install uses 'copilot'." + ) + + +def test_root_and_reference_matching_integration_has_no_warning(): + _, expanded = _expanded_root( + root_integration={"id": "copilot"}, child_integration={"id": "copilot"} + ) + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + assert not any("targets" in warning for warning in plan.warnings) + + +def test_explicit_integration_override_is_used_for_reference_warning(): + _, expanded = _expanded_root( + root_integration={"id": "copilot"}, child_integration={"id": "claude"} + ) + plan = resolve_install_plan( + expanded, + speckit_version="0.11.2", + active_integration=None, + integration_explicit=True, + ) + assert "this install uses 'copilot'" in plan.warnings[-1] + + +def test_reference_integration_unknown_is_a_warning(): + _, expanded = _expanded_root(child_integration={"id": "copilot"}) + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration=None + ) + assert plan.warnings[-1] == ( + "Bundle 'bugfix' (team → bugfix) targets integration 'copilot'; " + "the project's integration could not be determined." + ) + + +def test_expansion_warnings_are_appended_after_resolver_warnings(): + _, expanded = _expanded_root( + root_requires={ + "speckit_version": ">=0.1.0", + "tools": ["docker"], + "mcp": ["root-server"], + }, + child_requires={"speckit_version": ">=0.1.0", "mcp": ["child-server"]}, + child_integration={"id": "claude"}, + child_extensions=[{"id": "root-ext", "version": "2.0.0"}], + ) + plan = resolve_install_plan( + expanded, speckit_version="0.11.2", active_integration="copilot" + ) + assert plan.warnings == [ + "Requires external tools: docker", + "Requires MCP servers: root-server, child-server", + "Bundle 'bugfix' (team → bugfix) targets 'claude'; this install uses 'copilot'.", + expanded.warnings[-1], + ] + + +def test_flat_tools_are_not_deduplicated(): + manifest_data = valid_manifest_dict( + requires={"speckit_version": ">=0.1.0", "tools": ["git", "git"]} + ) + plan = resolve_install_plan( + BundleManifest.from_dict(manifest_data), + speckit_version="0.11.2", + active_integration="copilot", + ) + assert plan.warnings == ["Requires external tools: git, git"] diff --git a/tests/specify_cli/bundles/test_sources.py b/tests/specify_cli/bundles/test_sources.py index 04286d16fb..9af5484000 100644 --- a/tests/specify_cli/bundles/test_sources.py +++ b/tests/specify_cli/bundles/test_sources.py @@ -13,14 +13,25 @@ from specify_cli import app from specify_cli.bundles import BundlerError from specify_cli.bundles import sources as bundle_sources -from specify_cli.bundles.catalogs import CatalogEntry +from specify_cli.bundles.catalog_stack import CatalogStack +from specify_cli.bundles.catalogs import ( + CatalogEntry, + CatalogSource, + InstallPolicy, + Scope, +) +from specify_cli.bundles.expansion import expand_manifest +from specify_cli.bundles.manifest import BundleManifest, BundleRef from specify_cli.bundles.sources import ( + CatalogBundleLoader, _download_manifest, _local_manifest_source, _require_https, ) from tests.specify_cli.bundles.helpers import ( catalog_entry_dict, + catalog_payload, + reference_manifest_dict, valid_manifest_dict, write_manifest, ) @@ -67,6 +78,400 @@ def fake_open_url( monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) +def _catalog_source( + source_id: str, + priority: int, + policy: InstallPolicy = InstallPolicy.INSTALL_ALLOWED, +) -> CatalogSource: + return CatalogSource( + id=source_id, + url=f"builtin://{source_id}", + priority=priority, + install_policy=policy, + scope=Scope.PROJECT, + ) + + +def _catalog_stack(sources, payloads) -> CatalogStack: + return CatalogStack(sources, lambda source: payloads[source.id]) + + +def _manifest(bundle_id: str, version: str = "1.2.0"): + data = valid_manifest_dict() + data["bundle"]["id"] = bundle_id + data["bundle"]["version"] = version + return BundleManifest.from_dict(data) + + +def _resolved_catalog_entry( + bundle_id: str = "bugfix", + version: str = "1.2.0", + **overrides, +): + return catalog_entry_dict( + bundle_id, + version=version, + download_url=f"https://example.com/{bundle_id}-{version}.zip", + **overrides, + ) + + +def test_catalog_loader_resolves_pinned_release_and_returns_source_policy(): + stack = _catalog_stack( + [_catalog_source("default", 1)], + {"default": catalog_payload({"bugfix": _resolved_catalog_entry()})}, + ) + loader = CatalogBundleLoader( + stack, + offline=True, + download=lambda resolved, *, offline: _manifest( + resolved.entry.id, resolved.entry.version + ), + ) + + loaded = loader.load(BundleRef("bugfix", "1.2.0"), ("team",)) + + assert loaded.manifest.bundle.id == "bugfix" + assert loaded.manifest.bundle.version == "1.2.0" + assert loaded.source_id == "default" + assert loaded.install_allowed is True + + +def test_catalog_loader_omitted_source_uses_effective_stack_winner(): + stack = _catalog_stack( + [_catalog_source("default", 1), _catalog_source("community", 20)], + { + "default": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="2.0.0")} + ), + "community": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.0.0")} + ), + }, + ) + calls = [] + + def download(resolved, *, offline): + calls.append((resolved.source.id, offline)) + return _manifest("bugfix", resolved.entry.version) + + loaded = CatalogBundleLoader(stack, offline=True, download=download).load( + BundleRef("bugfix", "2.0.0"), ("team",) + ) + + assert loaded.source_id == "default" + assert calls == [("default", True)] + + +def test_catalog_loader_omitted_source_uses_higher_priority_non_default_catalog(): + stack = _catalog_stack( + [_catalog_source("community", 1), _catalog_source("default", 20)], + { + "community": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.2.0")} + ), + "default": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.2.0")} + ), + }, + ) + calls = [] + + def download(resolved, *, offline): + calls.append(resolved.source.id) + return _manifest("bugfix", resolved.entry.version) + + loaded = CatalogBundleLoader(stack, offline=True, download=download).load( + BundleRef("bugfix", "1.2.0"), ("team",) + ) + + assert loaded.source_id == "community" + assert calls == ["community"] + + +def test_catalog_loader_project_default_definition_overrides_builtin_default( + tmp_path: Path, +): + project_catalog = tmp_path / "project-catalog.json" + project_catalog.write_text( + yaml.safe_dump(catalog_payload({ + "bugfix": _resolved_catalog_entry(version="1.2.0"), + })), + encoding="utf-8", + ) + config = tmp_path / ".specify" + config.mkdir() + (config / "bundle-catalogs.yml").write_text( + yaml.safe_dump({ + "catalogs": [{ + "id": "default", + "url": str(project_catalog), + "priority": 1, + "install_policy": "install-allowed", + }], + }), + encoding="utf-8", + ) + + stack = CatalogStack.load( + tmp_path, + lambda source: yaml.safe_load(Path(source.url).read_text(encoding="utf-8")), + ) + calls = [] + + def download(resolved, *, offline): + calls.append((resolved.source.id, resolved.source.url)) + return _manifest("bugfix", resolved.entry.version) + + loaded = CatalogBundleLoader(stack, offline=True, download=download).load( + BundleRef("bugfix", "1.2.0"), ("team",) + ) + + assert loaded.source_id == "default" + assert calls == [("default", str(project_catalog))] + + +def test_catalog_loader_missing_release_does_not_fall_through_to_lower_catalog(): + stack = _catalog_stack( + [_catalog_source("project", 1), _catalog_source("default", 20)], + { + "project": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="2.0.0")} + ), + "default": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.2.0")} + ), + }, + ) + calls = [] + + def download(resolved, *, offline): + calls.append(resolved) + return _manifest("bugfix", resolved.entry.version) + + with pytest.raises(BundlerError, match="catalog 'project' offers 2\\.0\\.0"): + CatalogBundleLoader(stack, offline=True, download=download).load( + BundleRef("bugfix", "1.2.0"), ("team",) + ) + + assert calls == [] + + +def test_catalog_loader_expansion_wraps_missing_release_and_source_failures(): + cases = [ + ( + BundleRef("bugfix", "0.9.0"), + "Update the reference", + ), + ( + BundleRef("bugfix", "1.2.0", "default"), + "winning catalog is 'community'", + ), + ] + for reference, cause in cases: + stack = _catalog_stack( + [_catalog_source("community", 1)], + { + "community": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.2.0")} + ), + }, + ) + root = BundleManifest.from_dict( + reference_manifest_dict([{ + "id": reference.id, + "version": reference.version, + **({"source": reference.source} if reference.source else {}), + }], bundle_id="team") + ) + + with pytest.raises(BundlerError) as excinfo: + expand_manifest( + root, + CatalogBundleLoader(stack, offline=True, download=lambda *_args, **_kwargs: _manifest("bugfix")), + ) + + message = str(excinfo.value) + if reference.source: + assert message.startswith("Bundle reference bugfix (team)") + assert "Cannot resolve bundle reference" not in message + else: + assert message.startswith("Cannot resolve bundle reference bugfix@") + assert message.count("Cannot resolve bundle reference bugfix@") == 1 + assert cause in message + + +def test_catalog_loader_real_download_path_honors_offline_mode(): + stack = _catalog_stack( + [_catalog_source("default", 1)], + {"default": catalog_payload({"bugfix": _resolved_catalog_entry()})}, + ) + + with pytest.raises(BundlerError, match="Network access disabled"): + CatalogBundleLoader(stack, offline=True).load( + BundleRef("bugfix", "1.2.0"), ("team",) + ) + + +def test_catalog_loader_accepts_matching_explicit_source(): + stack = _catalog_stack( + [_catalog_source("team-catalog", 1)], + { + "team-catalog": catalog_payload( + {"bugfix": _resolved_catalog_entry()} + ) + }, + ) + loaded = CatalogBundleLoader( + stack, + offline=False, + download=lambda resolved, *, offline: _manifest( + resolved.entry.id, resolved.entry.version + ), + ).load(BundleRef("bugfix", "1.2.0", "team-catalog"), ("team",)) + + assert loaded.source_id == "team-catalog" + + +def test_catalog_loader_rejects_source_mismatch_before_download(): + stack = _catalog_stack( + [_catalog_source("project", 1), _catalog_source("default", 20)], + { + "project": catalog_payload({"bugfix": _resolved_catalog_entry()}), + "default": catalog_payload({"bugfix": _resolved_catalog_entry()}), + }, + ) + calls = [] + + def download(resolved, *, offline): + calls.append(resolved) + return _manifest("bugfix") + + loader = CatalogBundleLoader(stack, offline=False, download=download) + with pytest.raises(BundlerError, match="winning catalog is 'project'"): + loader.load(BundleRef("bugfix", "1.2.0", "default"), ("team",)) + + assert calls == [] + + +def test_catalog_loader_missing_release_names_pin_and_offered_versions(): + stack = _catalog_stack( + [_catalog_source("default", 1)], + { + "default": catalog_payload( + { + "bugfix": _resolved_catalog_entry( + version="2.0.0", + releases={ + "1.0.0": { + "download_url": "https://example.com/bugfix-1.0.0.zip", + "sha256": "a" * 64, + } + }, + ) + } + ) + }, + ) + + with pytest.raises( + BundlerError, + match=( + r"'team' pins bugfix 0\.9\.0, but catalog 'default' offers " + r"2\.0\.0, 1\.0\.0\. Update the reference" + ), + ): + CatalogBundleLoader(stack, offline=True).load( + BundleRef("bugfix", "0.9.0"), ("team",) + ) + + +def test_catalog_loader_propagates_unrelated_resolve_error(): + stack = _catalog_stack( + [_catalog_source("default", 1)], + {"default": catalog_payload({})}, + ) + + with pytest.raises(BundlerError) as excinfo: + CatalogBundleLoader(stack, offline=True).load( + BundleRef("missing", "0.9.0"), ("team",) + ) + + assert str(excinfo.value) == ( + "Bundle 'missing' was not found in any configured catalog." + ) + assert "pins" not in str(excinfo.value) + + +def test_catalog_loader_propagates_offline_download_error(): + stack = _catalog_stack( + [_catalog_source("default", 1)], + {"default": catalog_payload({"bugfix": _resolved_catalog_entry()})}, + ) + error = BundlerError("Network access disabled; cannot download bundle 'bugfix'.") + + def download(resolved, *, offline): + assert offline is True + raise error + + with pytest.raises(BundlerError) as excinfo: + CatalogBundleLoader(stack, offline=True, download=download).load( + BundleRef("bugfix", "1.2.0"), ("team",) + ) + + assert excinfo.value is error + + +def test_catalog_loader_returns_discovery_only_policy(): + stack = _catalog_stack( + [_catalog_source("community", 1, InstallPolicy.DISCOVERY_ONLY)], + {"community": catalog_payload({"bugfix": _resolved_catalog_entry()})}, + ) + loaded = CatalogBundleLoader( + stack, + offline=True, + download=lambda resolved, *, offline: _manifest( + resolved.entry.id, resolved.entry.version + ), + ).load(BundleRef("bugfix", "1.2.0"), ("team",)) + + assert loaded.source_id == "community" + assert loaded.install_allowed is False + + +def test_catalog_loader_expands_through_real_catalog_stack(): + stack = _catalog_stack( + [_catalog_source("default", 1)], + {"default": catalog_payload({"bugfix": _resolved_catalog_entry()})}, + ) + root = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": "bugfix", "version": "1.2.0"}], + bundle_id="team", + ) + ) + expanded = expand_manifest( + root, + CatalogBundleLoader( + stack, + offline=True, + download=lambda resolved, *, offline: _manifest( + resolved.entry.id, resolved.entry.version + ), + ), + ) + + assert [(ref.id, ref.source_id) for ref in expanded.references] == [ + ("bugfix", "default") + ] + assert [component.id for component in expanded.components] == [ + "ext-a", + "preset-a", + "step-a", + "wf-a", + ] + + def test_local_source_none_for_non_path(): assert _local_manifest_source("some-catalog-bundle-id") is None diff --git a/tests/specify_cli/bundles/test_validator.py b/tests/specify_cli/bundles/test_validator.py index 19b6ba380e..e0151090ac 100644 --- a/tests/specify_cli/bundles/test_validator.py +++ b/tests/specify_cli/bundles/test_validator.py @@ -4,7 +4,7 @@ import pytest from specify_cli.bundles import validator as validator_mod -from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles.manifest import BundleManifest, ComponentRef from specify_cli.bundles.validator import validate_manifest from tests.specify_cli.bundles.helpers import valid_manifest_dict @@ -33,6 +33,40 @@ def test_duplicate_component_id_cannot_request_two_versions(): ) +def test_components_override_is_checked(): + manifest = _manifest() + expanded_component = ComponentRef( + kind="extensions", id="expanded-only", version="1.0.0" + ) + + report = validate_manifest( + manifest, + reference_checker=lambda component: "not found", + components=[expanded_component], + ) + + assert report.errors == [ + "Unresolved reference extension:expanded-only@1.0.0: not found" + ] + + +def test_unresolved_expanded_component_is_reported(): + manifest = _manifest() + expanded_component = ComponentRef( + kind="extensions", id="expanded-only", version="1.0.0" + ) + + report = validate_manifest( + manifest, + reference_checker=lambda component: ( + "missing expanded component" if component.id == "expanded-only" else None + ), + components=[*manifest.components, expanded_component], + ) + + assert any("expanded-only" in error for error in report.errors) + + def test_non_bundler_error_not_swallowed(monkeypatch): # A programming error inside constraint parsing must propagate, not be # masked behind an "invalid constraint" validation message. From 2dd334e1c651cb47cb02649c585affc1591fab97 Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 23 Oct 2026 07:05:34 +0200 Subject: [PATCH 02/27] fix(bundles): resolve reference review feedback Address extension-priority conflicts and provenance merging, clarify\noffline/local-preview behavior, and document validation's runtime-version\ngate boundary.\n\nAssisted-by: pi (model: gpt-5.6-terra, autonomous) --- docs/reference/bundles.md | 16 +++-- src/specify_cli/bundles/catalog_stack.py | 11 ++- src/specify_cli/bundles/command_info.py | 5 ++ src/specify_cli/bundles/conflict.py | 12 ++-- src/specify_cli/bundles/expansion.py | 18 +++-- src/specify_cli/bundles/manifest.py | 2 +- src/specify_cli/bundles/sources.py | 10 ++- .../specify_cli/bundles/test_command_info.py | 18 +++++ .../bundles/test_command_install.py | 17 +++++ tests/specify_cli/bundles/test_conflict.py | 4 +- tests/specify_cli/bundles/test_expansion.py | 71 +++++++++++++++++++ tests/specify_cli/bundles/test_sources.py | 20 +++++- 12 files changed, 177 insertions(+), 27 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 44157ed5da..3e7bf49b53 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -149,7 +149,8 @@ recursively in declaration order (depth-first), followed by the declaring bundle's own components, while preserving the existing extension, preset, step, and workflow grouping. A component is installed at most once per `kind:id`. Copies agree when their version and `source` match (omitted and set `source` -values differ); preset copies must also have the same `priority` and `strategy`. +values differ); extension and preset copies must also have the same `priority`, +and preset copies must also have the same `strategy`. Other differences between sibling references are conflicts that must be fixed in the parent. At every level, a bundle's own declaration wins over its references, takes the declaration's position, and emits a warning describing @@ -171,10 +172,13 @@ records. `bundle update` follows the parent's current manifest and its pinned references; remove and refresh retain the existing ownership and component lifecycle rules. -Referenced manifests require network access. `install`, `info`, and `update` -fail offline with the reference named; `validate --offline` warns once per -declared reference that it was not expanded. `bundle build` performs only the -structural checks for references and does not resolve or embed them. +Referenced manifests require network access. For a local root manifest, +`install` can identify the first reference and fail offline with that reference +named. A catalog-root `install`, `info`, or `update` can instead fail earlier +while fetching its catalog or root manifest, before any reference can be known. +`validate --offline` warns once per declared reference that it was not expanded. +`bundle build` performs only the structural checks for references and does not +resolve or embed them. **Known limitations.** @@ -285,7 +289,7 @@ specify bundle validate | `--path` | Bundle directory or `bundle.yml` (default: current directory) | | `--offline` | Verify references against bundled/installed components only | -Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. For a reference-bearing manifest, online validation expands the complete reference graph and checks every component in the expanded plan, including structural, cycle, version, source, conflict, and install-policy checks. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. In `--offline` mode, each declared bundle reference produces a warning that it was not expanded. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins. +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. For a reference-bearing manifest, online validation expands the complete reference graph and checks every component in the expanded plan, including structural, cycle, version, source, conflict, and install-policy checks. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. In `--offline` mode, each declared bundle reference produces a warning that it was not expanded. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. Validation does not evaluate `requires.speckit_version` against the running CLI; `install` and `update` enforce that runtime compatibility gate. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins. ## Build a Bundle Artifact diff --git a/src/specify_cli/bundles/catalog_stack.py b/src/specify_cli/bundles/catalog_stack.py index a8ea7df44d..97930ec4cc 100644 --- a/src/specify_cli/bundles/catalog_stack.py +++ b/src/specify_cli/bundles/catalog_stack.py @@ -22,6 +22,13 @@ CatalogFetcher = Callable[[CatalogSource], dict] +class BundleNotFoundError(BundlerError): + """A bundle ID is absent from every configured catalog.""" + + def __init__(self, bundle_id: str) -> None: + super().__init__(f"Bundle '{bundle_id}' was not found in any configured catalog.") + + class ReleaseNotFoundError(BundlerError): """A requested release is absent from the winning catalog entry.""" @@ -112,9 +119,7 @@ def resolve(self, bundle_id: str, version: str | None = None) -> ResolvedBundle: entry=selected.with_provenance(source), source=source, ) - raise BundlerError( - f"Bundle '{bundle_id}' was not found in any configured catalog." - ) + raise BundleNotFoundError(bundle_id) def search(self, query: str = "") -> list[ResolvedBundle]: """Return entries matching *query* (substring over id/name/role/tags/description). diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py index fcaa01df82..5c9300addd 100644 --- a/src/specify_cli/bundles/command_info.py +++ b/src/specify_cli/bundles/command_info.py @@ -34,6 +34,11 @@ def bundle_info( ) -> None: """Show full metadata and the fully expanded component set (== what install adds).""" try: + if Path(bundle_id).expanduser().exists(): + raise BundlerError( + "Local bundle preview is not supported; 'specify bundle info' " + "accepts a catalog bundle ID. Add the bundle to a catalog to preview it." + ) project_root = find_project_root() or Path.cwd() stack = _build_stack(project_root, offline=offline) resolved = stack.resolve(bundle_id) diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index b29c789a05..b6fed2cbe5 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -22,17 +22,17 @@ def has_blocking_conflict(self) -> bool: def component_setting_differences(a: ComponentRef, b: ComponentRef) -> list[str]: """Return install-affecting setting fields that differ between two refs. - Compares ``source`` and, for presets only, ``priority`` and ``strategy``. + Compares ``source``, ``priority`` for extensions and presets, and + ``strategy`` for presets only. Versions are intentionally not compared. """ different = [] if a.source != b.source: different.append("source") - if a.kind == "presets": - if a.priority != b.priority: - different.append("priority") - if a.strategy != b.strategy: - different.append("strategy") + if a.kind in ("extensions", "presets") and a.priority != b.priority: + different.append("priority") + if a.kind == "presets" and a.strategy != b.strategy: + different.append("strategy") return different diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py index c501464102..b247ed0f32 100644 --- a/src/specify_cli/bundles/expansion.py +++ b/src/specify_cli/bundles/expansion.py @@ -373,15 +373,21 @@ def _merge_candidate( # A shared dependency can arrive through multiple routes. Merge an # agreeing copy into the existing candidate instead of retaining a - # duplicate override entry. Preserve the first component spelling while - # taking the shorter provenance and its inherited overrides. + # duplicate override entry. Preserve the first component spelling and the + # shorter provenance, while keeping distinct override histories from both + # routes for `bundle info`. for index, fields in enumerate(differences): if not fields: previous = existing[index] - if len(entry.origin) < len(previous.origin): - existing[index] = _Entry( - previous.component, entry.origin, entry.overrides - ) + overrides = tuple( + dict.fromkeys((*previous.overrides, *entry.overrides)) + ) + origin = ( + entry.origin + if len(entry.origin) < len(previous.origin) + else previous.origin + ) + existing[index] = _Entry(previous.component, origin, overrides) return if key in own_keys: diff --git a/src/specify_cli/bundles/manifest.py b/src/specify_cli/bundles/manifest.py index ea749ee371..edfc04cc8f 100644 --- a/src/specify_cli/bundles/manifest.py +++ b/src/specify_cli/bundles/manifest.py @@ -34,7 +34,7 @@ class ComponentRef: id: str version: str | None = None source: str | None = None - priority: int | None = None # presets only + priority: int | None = None # extensions and presets only strategy: str | None = None # presets only def label(self) -> str: diff --git a/src/specify_cli/bundles/sources.py b/src/specify_cli/bundles/sources.py index c5d92c2c5f..177472195d 100644 --- a/src/specify_cli/bundles/sources.py +++ b/src/specify_cli/bundles/sources.py @@ -11,7 +11,7 @@ read_response_limited, ) from . import BundlerError -from .catalog_stack import ReleaseNotFoundError +from .catalog_stack import BundleNotFoundError, ReleaseNotFoundError from .expansion import LoadedBundle, SourceMismatchError from .manifest import BundleRef from .versioning import same_version @@ -114,6 +114,14 @@ def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: """ try: resolved = self._stack.resolve(ref.id, ref.version) + except BundleNotFoundError as exc: + if self._offline: + raise BundlerError( + f"Network access disabled; cannot resolve bundle reference " + f"{ref.id}@{ref.version or ''} while offline. " + "Re-run without --offline." + ) from exc + raise except ReleaseNotFoundError as exc: missing = _missing_release_error(ref, chain, exc) if missing is not None: diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 99ad5d4f07..9472f7c245 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -76,6 +76,24 @@ def test_info_unknown_bundle_without_project_reports_not_found( assert "Spec Kit project" not in result.output +def test_info_rejects_local_bundle_path_with_explicit_guidance(project: Path): + local_bundle = project / "local-bundle.yml" + local_bundle.write_text( + yaml.safe_dump( + reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], bundle_id="local" + ) + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["bundle", "info", str(local_bundle)]) + + assert result.exit_code == 1, result.output + assert "Local bundle preview is not supported" in result.output + assert "was not found in any configured catalog" not in result.output + + def test_info_renders_reference_provenance(project: Path, monkeypatch): parent_dir = project / "parent-bundle" child_dir = project / "child-bundle" diff --git a/tests/specify_cli/bundles/test_command_install.py b/tests/specify_cli/bundles/test_command_install.py index 87a3b925b3..1738ff8aa5 100644 --- a/tests/specify_cli/bundles/test_command_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -443,6 +443,23 @@ def offline_download(selected, *, offline): assert not (project / ".specify/bundle-records.json").exists() +def test_local_reference_bundle_offline_names_reference_and_network_limit(project: Path): + parent = write_manifest( + project / "parent", + reference_manifest_dict( + [{"id": "missing-child", "version": "1.0.0"}], bundle_id="parent" + ), + ) + + result = runner.invoke(app, ["bundle", "install", str(parent), "--offline"]) + + assert result.exit_code == 1, result.output + output = " ".join(result.output.split()) + assert "Cannot resolve bundle reference missing-child@1.0.0 (parent)" in output + assert "Network access disabled" in output + assert "not found in any configured catalog" not in output + + def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): bundle_dir = project / "mismatched-workflow-pin" (bundle_dir / "bundle.yml").parent.mkdir() diff --git a/tests/specify_cli/bundles/test_conflict.py b/tests/specify_cli/bundles/test_conflict.py index 5d8e45aa9c..012a076f26 100644 --- a/tests/specify_cli/bundles/test_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -193,10 +193,10 @@ def test_setting_differences_ignores_versions(): ) == [] -def test_setting_differences_ignores_preset_fields_for_non_presets(): +def test_setting_differences_reports_extension_priority_but_ignores_strategy(): a = ComponentRef(kind="extensions", id="e", priority=1, strategy="append") b = ComponentRef(kind="extensions", id="e", priority=2, strategy="replace") - assert component_setting_differences(a, b) == [] + assert component_setting_differences(a, b) == ["priority"] def test_setting_differences_reports_source_for_any_kind(): diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py index 25153513fc..ff375b4606 100644 --- a/tests/specify_cli/bundles/test_expansion.py +++ b/tests/specify_cli/bundles/test_expansion.py @@ -193,6 +193,58 @@ def test_shared_dependency_override_is_merged_once(): assert "root → second → dependency" not in expanded.warnings[0] +def test_agreeing_copies_merge_override_histories_regardless_of_order(): + leaf = manifest("leaf", extensions=[{"id": "shared", "version": "1.0.0"}]) + plain = manifest("plain", extensions=[{"id": "shared", "version": "2.0.0"}]) + overriding = manifest( + "overriding", + bundles=[{"id": "leaf", "version": "1.0.0"}], + extensions=[{"id": "shared", "version": "2.0.0"}], + ) + + for reference_ids in (("plain", "overriding"), ("overriding", "plain")): + root = manifest( + "root", + bundles=[{"id": bundle_id, "version": "1.0.0"} for bundle_id in reference_ids], + ) + expanded = expand_manifest( + root, FakeBundleLoader(loaded(leaf, plain, overriding)) + ) + + overrides = expanded.origins[("extensions", "shared")].overrides + assert [(item.component.version, item.origin) for item in overrides] == [ + ("1.0.0", ("overriding", "leaf")) + ] + + +def test_shorter_origin_preserves_agreeing_copy_override_history(): + leaf = manifest("leaf", extensions=[{"id": "shared", "version": "1.0.0"}]) + overriding = manifest( + "overriding", + bundles=[{"id": "leaf", "version": "1.0.0"}], + extensions=[{"id": "shared", "version": "2.0.0"}], + ) + long = manifest("long", bundles=[{"id": "overriding", "version": "1.0.0"}]) + plain = manifest("plain", extensions=[{"id": "shared", "version": "2.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "long", "version": "1.0.0"}, + {"id": "plain", "version": "1.0.0"}, + ], + ) + + expanded = expand_manifest( + root, FakeBundleLoader(loaded(leaf, overriding, long, plain)) + ) + + origin = expanded.origins[("extensions", "shared")] + assert origin.origin == ("plain",) + assert [(item.component.version, item.origin) for item in origin.overrides] == [ + ("1.0.0", ("long", "overriding", "leaf")) + ] + + def test_agreeing_copy_keeps_first_component_spelling_and_shorter_origin(): dependency = manifest("dependency", extensions=[{"id": "e", "version": "v1.0.0"}]) first = manifest("first", bundles=[{"id": "dependency", "version": "1.0.0"}]) @@ -238,6 +290,24 @@ def test_own_override_records_all_fields_and_own_position(): assert expanded.warnings.count(warning) == 1 +def test_extension_priority_only_override_is_reported(): + child = manifest( + "child", extensions=[{"id": "style", "version": "1.0.0", "priority": 10}] + ) + root = manifest( + "root", + bundles=[{"id": "child", "version": "1.0.0"}], + extensions=[{"id": "style", "version": "1.0.0", "priority": 20}], + ) + + expanded = expand_manifest(root, FakeBundleLoader(loaded(child))) + + origin = expanded.origins[("extensions", "style")] + assert expanded.components[0].priority == 20 + assert origin.overrides[0].origin == ("child",) + assert "priority 20 (root) overrides priority 10 from root → child" in expanded.warnings[0] + + def test_own_override_and_agreeing_copy_keep_own_positions(): child = manifest( "child", @@ -289,6 +359,7 @@ def test_nested_override_is_resolved_before_parent_merge(): ("extensions", {"version": "1.0.0"}, {"version": None}, "version", "1.0.0", ""), ("extensions", {"source": "a"}, {"source": "b"}, "source", "a", "b"), ("extensions", {"source": None}, {"source": "a"}, "source", "", "a"), + ("extensions", {"priority": 10}, {"priority": 20}, "priority", "10", "20"), ("presets", {"priority": 1}, {"priority": 2}, "priority", "1", "2"), ("presets", {"strategy": "append"}, {"strategy": "replace"}, "strategy", "append", "replace"), ], diff --git a/tests/specify_cli/bundles/test_sources.py b/tests/specify_cli/bundles/test_sources.py index 9af5484000..75ab22a5ba 100644 --- a/tests/specify_cli/bundles/test_sources.py +++ b/tests/specify_cli/bundles/test_sources.py @@ -386,7 +386,7 @@ def test_catalog_loader_missing_release_names_pin_and_offered_versions(): ) -def test_catalog_loader_propagates_unrelated_resolve_error(): +def test_catalog_loader_offline_missing_bundle_reports_network_limit(): stack = _catalog_stack( [_catalog_source("default", 1)], {"default": catalog_payload({})}, @@ -397,10 +397,26 @@ def test_catalog_loader_propagates_unrelated_resolve_error(): BundleRef("missing", "0.9.0"), ("team",) ) + assert str(excinfo.value) == ( + "Network access disabled; cannot resolve bundle reference missing@0.9.0 " + "while offline. Re-run without --offline." + ) + + +def test_catalog_loader_online_missing_bundle_reports_catalog_miss(): + stack = _catalog_stack( + [_catalog_source("default", 1)], + {"default": catalog_payload({})}, + ) + + with pytest.raises(BundlerError) as excinfo: + CatalogBundleLoader(stack, offline=False).load( + BundleRef("missing", "0.9.0"), ("team",) + ) + assert str(excinfo.value) == ( "Bundle 'missing' was not found in any configured catalog." ) - assert "pins" not in str(excinfo.value) def test_catalog_loader_propagates_offline_download_error(): From 9d538b777da64e20705295ead6d09e7991ebac48 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 08:59:14 +0200 Subject: [PATCH 03/27] fix(bundles): keep catalog lookup for bundle info IDs Remove the local-path guard from bundle info so a bare catalog ID is not rejected when a same-named file or directory exists. Add a regression. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/command_info.py | 5 -- .../specify_cli/bundles/test_command_info.py | 50 ++++++++++++------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py index 5c9300addd..fcaa01df82 100644 --- a/src/specify_cli/bundles/command_info.py +++ b/src/specify_cli/bundles/command_info.py @@ -34,11 +34,6 @@ def bundle_info( ) -> None: """Show full metadata and the fully expanded component set (== what install adds).""" try: - if Path(bundle_id).expanduser().exists(): - raise BundlerError( - "Local bundle preview is not supported; 'specify bundle info' " - "accepts a catalog bundle ID. Add the bundle to a catalog to preview it." - ) project_root = find_project_root() or Path.cwd() stack = _build_stack(project_root, offline=offline) resolved = stack.resolve(bundle_id) diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 9472f7c245..944571c334 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -76,24 +76,6 @@ def test_info_unknown_bundle_without_project_reports_not_found( assert "Spec Kit project" not in result.output -def test_info_rejects_local_bundle_path_with_explicit_guidance(project: Path): - local_bundle = project / "local-bundle.yml" - local_bundle.write_text( - yaml.safe_dump( - reference_manifest_dict( - [{"id": "child", "version": "1.0.0"}], bundle_id="local" - ) - ), - encoding="utf-8", - ) - - result = runner.invoke(app, ["bundle", "info", str(local_bundle)]) - - assert result.exit_code == 1, result.output - assert "Local bundle preview is not supported" in result.output - assert "was not found in any configured catalog" not in result.output - - def test_info_renders_reference_provenance(project: Path, monkeypatch): parent_dir = project / "parent-bundle" child_dir = project / "child-bundle" @@ -218,6 +200,38 @@ def test_format_component_does_not_claim_unpinned_version_changed(): assert "overrides unpinned" not in text +@pytest.mark.parametrize("extra_args", [[], ["--versions"]]) +def test_info_catalog_id_ignores_same_named_path( + project: Path, monkeypatch, extra_args: list[str] +): + bundle_dir = project / "demo-bundle" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + catalog = project / "local-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + }, + ) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") + + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--offline", *extra_args] + ) + + assert result.exit_code == 0, result.output + assert "demo-bundle" in result.output + + def test_info_expands_full_component_set(project: Path, monkeypatch): bundle_dir = project / "src-bundle" bundle_dir.mkdir() From 5d7501e29a84f18fb98255222fa4852face319b0 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 08:59:14 +0200 Subject: [PATCH 04/27] fix(bundles): deduplicate override records across shared routes Identify an override by the discarded component and its declaring bundle and keep the shortest chain, so shared graphs no longer grow override histories exponentially. Applies to merged and inherited histories. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/expansion.py | 34 ++++++++-- tests/specify_cli/bundles/test_expansion.py | 73 +++++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py index b247ed0f32..f662b04989 100644 --- a/src/specify_cli/bundles/expansion.py +++ b/src/specify_cli/bundles/expansion.py @@ -313,16 +313,15 @@ def _expand( # Removing and re-adding gives an override the position of the own # declaration, rather than the first referenced occurrence. ordered.remove(key) - candidates[key] = [ - _Entry(component, (), tuple(inherited_overrides)) - ] + inherited = _merge_overrides(inherited_overrides) + candidates[key] = [_Entry(component, (), inherited)] ordered.append(key) if replaced: direct_overrides = tuple( ComponentOverride(entry.component, entry.origin) for entry in replaced ) candidates[key] = [ - _Entry(component, (), tuple(inherited_overrides) + direct_overrides) + _Entry(component, (), _merge_overrides(inherited, direct_overrides)) ] _append_notice( notices, @@ -379,9 +378,7 @@ def _merge_candidate( for index, fields in enumerate(differences): if not fields: previous = existing[index] - overrides = tuple( - dict.fromkeys((*previous.overrides, *entry.overrides)) - ) + overrides = _merge_overrides(previous.overrides, entry.overrides) origin = ( entry.origin if len(entry.origin) < len(previous.origin) @@ -395,6 +392,29 @@ def _merge_candidate( return +def _merge_overrides( + *histories: tuple[ComponentOverride, ...] | list[ComponentOverride], +) -> tuple[ComponentOverride, ...]: + """Merge override histories to one record per discarded declaration. + + The declaration is identified by the discarded component and the bundle + that declared it; the route to it is display-only, so only the shortest + chain is kept. Without this, every route through a shared graph would add + its own copy and the history would grow exponentially. + """ + best: dict[tuple[ComponentRef, str], ComponentOverride] = {} + for history in histories: + for override in history: + key = (override.component, override.origin[-1]) + current = best.get(key) + if current is None or (len(override.origin), override.origin) < ( + len(current.origin), + current.origin, + ): + best[key] = override + return tuple(best.values()) + + def _stable_keys(ordered: list[tuple[str, str]]) -> list[tuple[str, str]]: kind_order = {kind: index for index, kind in enumerate(COMPONENT_KINDS)} return sorted(ordered, key=lambda key: kind_order.get(key[0], len(kind_order))) diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py index ff375b4606..b279a699d4 100644 --- a/tests/specify_cli/bundles/test_expansion.py +++ b/tests/specify_cli/bundles/test_expansion.py @@ -697,3 +697,76 @@ def test_component_difference_uses_strict_pinned_rules(): assert _component_differences( ComponentRef("extensions", "e", "v1.0.0"), ComponentRef("extensions", "e", "1.0.0") ) == [] + + +def _layered_override_graph(layers: int): + """Build `layers` of two-way sharing above one bundle that overrides a leaf.""" + def refs(*ids): + return [{"id": item, "version": "1.0.0"} for item in ids] + + bundles = [ + manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]), + manifest( + "base", + bundles=refs("leaf"), + extensions=[{"id": "e", "version": "2.0.0"}], + ), + ] + previous: tuple[str, ...] = ("base",) + for layer in range(layers): + current = (f"l{layer}a", f"l{layer}b") + bundles.extend(manifest(name, bundles=refs(*previous)) for name in current) + previous = current + return bundles, previous + + +@pytest.mark.parametrize("layers", [1, 4, 7]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_layered_sharing_keeps_one_override_per_declaration(layers, reverse): + bundles, top = _layered_override_graph(layers) + ordered = tuple(reversed(top)) if reverse else top + root = manifest( + "root", bundles=[{"id": name, "version": "1.0.0"} for name in ordered] + ) + + expanded = expand_manifest(root, FakeBundleLoader(loaded(*bundles))) + + overrides = expanded.origins[("extensions", "e")].overrides + assert len(overrides) == 1 + assert overrides[0].component.version == "1.0.0" + expected_chain = ( + tuple(f"l{layer}a" for layer in reversed(range(layers))) + ("base", "leaf") + ) + assert overrides[0].origin == expected_chain + + +def test_distinct_discarded_declarations_stay_separate_records(): + first = manifest("first", extensions=[{"id": "e", "version": "1.0.0"}]) + second = manifest("second", extensions=[{"id": "e", "version": "3.0.0"}]) + base = manifest( + "base", + bundles=[ + {"id": "first", "version": "1.0.0"}, + {"id": "second", "version": "1.0.0"}, + ], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + left = manifest("left", bundles=[{"id": "base", "version": "1.0.0"}]) + right = manifest("right", bundles=[{"id": "base", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "left", "version": "1.0.0"}, + {"id": "right", "version": "1.0.0"}, + ], + ) + + expanded = expand_manifest( + root, FakeBundleLoader(loaded(first, second, base, left, right)) + ) + + overrides = expanded.origins[("extensions", "e")].overrides + assert sorted((item.component.version, item.origin) for item in overrides) == [ + ("1.0.0", ("left", "base", "first")), + ("3.0.0", ("left", "base", "second")), + ] From bba10387baf9467db8e3a23e60cbbf82c90488f5 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 08:59:14 +0200 Subject: [PATCH 05/27] fix(bundles): preserve flat-bundle extension priority rule Cross-bundle installed-pin checks compare priority for presets only, as before. Reference expansion still compares extension priority strictly. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/conflict.py | 13 +++--- src/specify_cli/bundles/installer.py | 7 ++- tests/specify_cli/bundles/test_installer.py | 47 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index b6fed2cbe5..cf2a1ecfca 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -19,17 +19,20 @@ def has_blocking_conflict(self) -> bool: return self.integration_clash is not None or bool(self.version_clashes) -def component_setting_differences(a: ComponentRef, b: ComponentRef) -> list[str]: +def component_setting_differences( + a: ComponentRef, b: ComponentRef, *, extension_priority: bool = True +) -> list[str]: """Return install-affecting setting fields that differ between two refs. - Compares ``source``, ``priority`` for extensions and presets, and - ``strategy`` for presets only. - Versions are intentionally not compared. + Compares ``source``, ``priority`` for presets and, unless + *extension_priority* is false, extensions, and ``strategy`` for presets + only. Versions are intentionally not compared. """ different = [] if a.source != b.source: different.append("source") - if a.kind in ("extensions", "presets") and a.priority != b.priority: + priority_kinds = ("extensions", "presets") if extension_priority else ("presets",) + if a.kind in priority_kinds and a.priority != b.priority: different.append("priority") if a.kind == "presets" and a.strategy != b.strategy: different.append("strategy") diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index a4087e25c4..1090796230 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -330,7 +330,12 @@ def _check_installed_pins( for component in plan.components: key = component.kind, component.id for bundle_id, required in other_requirements.get(key, []): - different = component_setting_differences(component, required) + # Cross-bundle sharing keeps the flat-bundle rule: an omitted + # extension priority already means the default, so only preset + # priority is compared here. + different = component_setting_differences( + component, required, extension_priority=False + ) if different: raise BundlerError( f"Cannot install or refresh shared {component.kind[:-1]} " diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index 473f044492..c1e13ae977 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -514,6 +514,53 @@ def test_shared_install_rejects_conflicting_preset_priority(tmp_path: Path): assert records_path(tmp_path).read_bytes() == before +def _extension_bundle(bundle_id: str, *, priority: int | None = None) -> BundleManifest: + data = valid_manifest_dict() + data["bundle"]["id"] = bundle_id + extension = {"id": "ext-shared", "version": "1.0.0"} + if priority is not None: + extension["priority"] = priority + data["provides"] = {"extensions": [extension]} + return BundleManifest.from_dict(data) + + +@pytest.mark.parametrize( + ("first_priority", "second_priority"), + [(None, 10), (10, None), (None, None), (5, 10), (10, 5)], +) +@pytest.mark.parametrize("refresh", [False, True]) +def test_flat_bundles_may_share_extension_with_any_priority( + tmp_path: Path, first_priority, second_priority, refresh +): + make_project(tmp_path) + installer = FakeInstaller() + first = _extension_bundle("first", priority=first_priority) + second = _extension_bundle("second", priority=second_priority) + install_bundle(tmp_path, _plan(first), installer, manifest=first) + install_bundle(tmp_path, _plan(second), installer, manifest=second) + + if refresh: + install_bundle( + tmp_path, _plan(second), installer, manifest=second, refresh=True + ) + + assert {record.bundle_id for record in load_records(tmp_path)} == {"first", "second"} + + +def test_shared_extension_source_must_still_match(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first = _extension_bundle("first") + second = _extension_bundle("second") + second.extensions[0] = ComponentRef( + kind="extensions", id="ext-shared", version="1.0.0", source="other" + ) + install_bundle(tmp_path, _plan(first), installer, manifest=first) + + with pytest.raises(BundlerError, match="shared extension.*source"): + install_bundle(tmp_path, _plan(second), installer, manifest=second) + + def test_shared_refresh_allows_matching_install_requirements(tmp_path: Path): make_project(tmp_path) installer = FakeInstaller() From 7462c92776875b70ad42bcad1c9a3dadc264f4cb Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 09:45:17 +0200 Subject: [PATCH 06/27] fix(bundles): normalize omitted extension priority before comparison An omitted extension priority installs as DEFAULT_PRIORITY (10), but component_setting_differences compared the raw None against 10. Sibling references declaring the same extension with omitted vs. explicit default priority failed expansion, and a parent declaration emitted a spurious override warning. Preset comparison and extension_priority=False are unchanged. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/conflict.py | 13 +++++- tests/specify_cli/bundles/test_conflict.py | 30 ++++++++++++++ tests/specify_cli/bundles/test_expansion.py | 44 +++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index cf2a1ecfca..2d124b4165 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from .manifest import BundleManifest, ComponentRef +from .primitives import DEFAULT_PRIORITY from .records import InstalledBundleRecord from .versioning import same_version @@ -19,20 +20,28 @@ def has_blocking_conflict(self) -> bool: return self.integration_clash is not None or bool(self.version_clashes) +def _priority(ref: ComponentRef) -> int | None: + # An omitted extension priority installs as DEFAULT_PRIORITY. + if ref.kind == "extensions" and ref.priority is None: + return DEFAULT_PRIORITY + return ref.priority + + def component_setting_differences( a: ComponentRef, b: ComponentRef, *, extension_priority: bool = True ) -> list[str]: """Return install-affecting setting fields that differ between two refs. Compares ``source``, ``priority`` for presets and, unless - *extension_priority* is false, extensions, and ``strategy`` for presets + *extension_priority* is false, extensions (omitted counts as the + default), and ``strategy`` for presets only. Versions are intentionally not compared. """ different = [] if a.source != b.source: different.append("source") priority_kinds = ("extensions", "presets") if extension_priority else ("presets",) - if a.kind in priority_kinds and a.priority != b.priority: + if a.kind in priority_kinds and _priority(a) != _priority(b): different.append("priority") if a.kind == "presets" and a.strategy != b.strategy: different.append("strategy") diff --git a/tests/specify_cli/bundles/test_conflict.py b/tests/specify_cli/bundles/test_conflict.py index 012a076f26..097783e6e3 100644 --- a/tests/specify_cli/bundles/test_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -1,8 +1,11 @@ """Unit tests for conflict detection (T034): integration clash and overlap precedence.""" from __future__ import annotations +import pytest + from specify_cli.bundles._commands import _bundle_overlaps from specify_cli.bundles.conflict import component_setting_differences, detect_conflicts +from specify_cli.bundles.primitives import DEFAULT_PRIORITY from specify_cli.bundles.manifest import BundleManifest, ComponentRef from specify_cli.bundles.records import InstalledBundleRecord, save_records from tests.specify_cli.bundles.helpers import valid_manifest_dict @@ -222,3 +225,30 @@ def test_setting_differences_preset_fields_in_stable_order(): assert component_setting_differences( a, ComponentRef(kind="presets", id="p", source="one", priority=1, strategy="replace") ) == ["strategy"] + + +@pytest.mark.parametrize("explicit_first", [False, True]) +def test_setting_differences_omitted_extension_priority_equals_default(explicit_first): + omitted = ComponentRef(kind="extensions", id="e") + default = ComponentRef(kind="extensions", id="e", priority=DEFAULT_PRIORITY) + a, b = (default, omitted) if explicit_first else (omitted, default) + assert component_setting_differences(a, b) == [] + + +def test_setting_differences_omitted_extension_priority_differs_from_non_default(): + omitted = ComponentRef(kind="extensions", id="e") + other = ComponentRef(kind="extensions", id="e", priority=DEFAULT_PRIORITY + 1) + assert component_setting_differences(omitted, other) == ["priority"] + assert component_setting_differences(other, omitted) == ["priority"] + + +def test_setting_differences_omitted_preset_priority_still_differs_from_default(): + omitted = ComponentRef(kind="presets", id="p") + default = ComponentRef(kind="presets", id="p", priority=DEFAULT_PRIORITY) + assert component_setting_differences(omitted, default) == ["priority"] + + +def test_setting_differences_extension_priority_opt_out_ignores_priority(): + omitted = ComponentRef(kind="extensions", id="e") + other = ComponentRef(kind="extensions", id="e", priority=DEFAULT_PRIORITY + 1) + assert component_setting_differences(omitted, other, extension_priority=False) == [] diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py index b279a699d4..4f5d6db2dd 100644 --- a/tests/specify_cli/bundles/test_expansion.py +++ b/tests/specify_cli/bundles/test_expansion.py @@ -770,3 +770,47 @@ def test_distinct_discarded_declarations_stay_separate_records(): ("1.0.0", ("left", "base", "first")), ("3.0.0", ("left", "base", "second")), ] + + +@pytest.mark.parametrize("omitted_first", [False, True]) +def test_sibling_omitted_and_default_extension_priority_agree(omitted_first): + omitted = {"id": "style", "version": "1.0.0"} + default = {"id": "style", "version": "1.0.0", "priority": 10} + first = manifest("first", extensions=[omitted if omitted_first else default]) + second = manifest("second", extensions=[default if omitted_first else omitted]) + root = manifest( + "root", + bundles=[{"id": "first", "version": "1.0.0"}, {"id": "second", "version": "1.0.0"}], + ) + + expanded = expand_manifest(root, FakeBundleLoader(loaded(first, second))) + + assert [ref.id for ref in expanded.components] == ["style"] + assert expanded.warnings == [] + + +@pytest.mark.parametrize("parent_explicit", [False, True]) +def test_parent_declaration_with_equivalent_extension_priority_is_not_an_override(parent_explicit): + child_ref = {"id": "style", "version": "1.0.0", **({} if parent_explicit else {"priority": 10})} + parent_ref = {"id": "style", "version": "1.0.0", **({"priority": 10} if parent_explicit else {})} + child = manifest("child", extensions=[child_ref]) + root = manifest( + "root", bundles=[{"id": "child", "version": "1.0.0"}], extensions=[parent_ref] + ) + + expanded = expand_manifest(root, FakeBundleLoader(loaded(child))) + + assert expanded.warnings == [] + assert expanded.origins[("extensions", "style")].overrides == () + + +def test_sibling_extension_priority_non_default_still_conflicts_with_omitted(): + first = manifest("first", extensions=[{"id": "style", "version": "1.0.0"}]) + second = manifest("second", extensions=[{"id": "style", "version": "1.0.0", "priority": 11}]) + root = manifest( + "root", + bundles=[{"id": "first", "version": "1.0.0"}, {"id": "second", "version": "1.0.0"}], + ) + + with pytest.raises(BundlerError, match="priority"): + expand_manifest(root, FakeBundleLoader(loaded(first, second))) From 5570973f7a703f8db6b60e4de4d8b263e91069bf Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 10:14:04 +0200 Subject: [PATCH 07/27] fix(bundles): share effective-setting rules with bundle info bundle info compared raw priority/strategy values, so an omitted extension priority showed as overridden by the default 10, and extension strategy changes were reported though only presets use strategy. Move the kind-specific rules into conflict.effective_setting and use it from both component_setting_differences and bundle info. Provenance without a kind keeps the raw comparison. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/command_info.py | 11 +++- src/specify_cli/bundles/conflict.py | 47 +++++++++----- .../specify_cli/bundles/test_command_info.py | 65 +++++++++++++++++++ 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py index fcaa01df82..a0b61acefe 100644 --- a/src/specify_cli/bundles/command_info.py +++ b/src/specify_cli/bundles/command_info.py @@ -20,6 +20,7 @@ _trust_level, bundle_app, ) +from .conflict import effective_setting from .project import find_project_root from .sources import _download_manifest from .versioning import same_version @@ -288,8 +289,16 @@ def _format_override(item: dict, override: dict) -> str: ): changed.append(str(old_version) if old_version else "unpinned") + kind = item.get("kind") for field in ("source", "priority", "strategy"): - if override.get(field) != item.get(field): + if kind is None: + # Provenance without a kind compares raw values. + differs = override.get(field) != item.get(field) + else: + differs = effective_setting( + kind, field, override.get(field) + ) != effective_setting(kind, field, item.get(field)) + if differs: value = override.get(field) changed.append(f"{field}={value if value is not None else ''}") diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index 2d124b4165..e23f40992e 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -20,11 +20,23 @@ def has_blocking_conflict(self) -> bool: return self.integration_clash is not None or bool(self.version_clashes) -def _priority(ref: ComponentRef) -> int | None: - # An omitted extension priority installs as DEFAULT_PRIORITY. - if ref.kind == "extensions" and ref.priority is None: - return DEFAULT_PRIORITY - return ref.priority +def effective_setting( + kind: str, field: str, value, *, extension_priority: bool = True +): + """Return *value* as it affects install for a component of *kind*. + + ``priority`` applies to presets and, when *extension_priority* is true, + extensions, where an omitted value installs as ``DEFAULT_PRIORITY``. + ``strategy`` applies to presets only. Settings that do not apply return + ``None``; ``source`` always applies. + """ + if field == "priority": + if kind == "extensions" and extension_priority: + return DEFAULT_PRIORITY if value is None else value + return value if kind == "presets" else None + if field == "strategy": + return value if kind == "presets" else None + return value def component_setting_differences( @@ -32,20 +44,19 @@ def component_setting_differences( ) -> list[str]: """Return install-affecting setting fields that differ between two refs. - Compares ``source``, ``priority`` for presets and, unless - *extension_priority* is false, extensions (omitted counts as the - default), and ``strategy`` for presets - only. Versions are intentionally not compared. + Compares ``source``, ``priority`` and ``strategy`` through + :func:`effective_setting`. Versions are intentionally not compared. """ - different = [] - if a.source != b.source: - different.append("source") - priority_kinds = ("extensions", "presets") if extension_priority else ("presets",) - if a.kind in priority_kinds and _priority(a) != _priority(b): - different.append("priority") - if a.kind == "presets" and a.strategy != b.strategy: - different.append("strategy") - return different + return [ + field + for field in ("source", "priority", "strategy") + if effective_setting( + a.kind, field, getattr(a, field), extension_priority=extension_priority + ) + != effective_setting( + b.kind, field, getattr(b, field), extension_priority=extension_priority + ) + ] def detect_conflicts( diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 944571c334..58143b6209 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -200,6 +200,45 @@ def test_format_component_does_not_claim_unpinned_version_changed(): assert "overrides unpinned" not in text +def _override_text(kind, item_fields, override_fields): + return _format_component( + { + "kind": kind, + "id": "shared", + "version": "2.0.0", + **item_fields, + "overrides": [ + {"version": "1.0.0", "origin": ["child-bundle"], **override_fields} + ], + } + ) + + +@pytest.mark.parametrize( + ("item_fields", "override_fields"), + [({"priority": 10}, {}), ({}, {"priority": 10})], +) +def test_format_component_omitted_extension_priority_equals_default(item_fields, override_fields): + text = _override_text("extensions", item_fields, override_fields) + assert "overrides 1.0.0 from child-bundle" in text + assert "priority=" not in text.split("overrides", 1)[1] + + +def test_format_component_extension_non_default_priority_is_reported(): + text = _override_text("extensions", {"priority": 11}, {}) + assert "overrides 1.0.0, priority= from child-bundle" in text + + +def test_format_component_ignores_extension_strategy(): + text = _override_text("extensions", {"strategy": "replace"}, {"strategy": "append"}) + assert "strategy=" not in text.split("overrides", 1)[1] + + +def test_format_component_preset_settings_still_compare_raw(): + text = _override_text("presets", {"priority": 10, "strategy": "replace"}, {"strategy": "append"}) + assert "overrides 1.0.0, priority=, strategy=append from child-bundle" in text + + @pytest.mark.parametrize("extra_args", [[], ["--versions"]]) def test_info_catalog_id_ignores_same_named_path( project: Path, monkeypatch, extra_args: list[str] @@ -1132,3 +1171,29 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None # Rich may wrap the message across lines; normalise whitespace before checking. output_flat = " ".join(result.output.split()) assert "exceeds maximum size of 100 bytes" in output_flat + + +@pytest.mark.parametrize("kind", ["extensions", "presets", "workflows"]) +@pytest.mark.parametrize( + ("field", "old", "new"), + [ + ("priority", None, 10), + ("priority", 10, None), + ("priority", 1, 2), + ("strategy", None, "append"), + ("strategy", "append", "replace"), + ], +) +def test_override_marker_agrees_with_component_setting_differences(kind, field, old, new): + from specify_cli.bundles.conflict import component_setting_differences + + def ref(value): + return ComponentRef(kind=kind, id="shared", **{field: value}) + + differs = field in component_setting_differences(ref(old), ref(new)) + text = _override_text( + kind, + {} if new is None else {field: new}, + {} if old is None else {field: old}, + ) + assert (f"{field}=" in text.split("overrides", 1)[1]) is differs From f52f54fdf315290e325a73d0f3b2dc9828368fcd Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 10:45:25 +0200 Subject: [PATCH 08/27] fix(bundles): expose trust status of referenced bundles in bundle info A verified root could reference a community bundle with no indication in `bundle info`. Carry the catalog entry's `verified` flag through LoadedBundle and ResolvedReference, and show it per reference in both the text label and JSON (`verified`, `trust`). Root trust fields stay scoped to the root. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/bundles/command_info.py | 3 + src/specify_cli/bundles/expansion.py | 4 ++ src/specify_cli/bundles/sources.py | 1 + .../specify_cli/bundles/test_command_info.py | 57 ++++++++++++++++++- .../bundles/test_command_install.py | 16 +++--- .../bundles/test_command_update.py | 18 +++--- .../bundles/test_command_validate.py | 4 +- 8 files changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 3e7bf49b53..91bff34373 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -55,7 +55,7 @@ specify bundle info --versions | `--json` | Emit machine-readable JSON | | `--versions` | List the current and historical catalog releases | -Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, and `via`; referenced component entries add `origin` and, when applicable, `overrides`. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. +Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary, with each referenced bundle's own trust level (`verified` or `community`), and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, `via`, and the referenced bundle's own `verified` and `trust`; referenced component entries add `origin` and, when applicable, `overrides`. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. Use `specify bundle info --versions` to inspect the catalog history without downloading a bundle manifest. With `--json`, it emits the bundle ID, available versions, current version, source, and install policy. diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py index a0b61acefe..d50d4f48f8 100644 --- a/src/specify_cli/bundles/command_info.py +++ b/src/specify_cli/bundles/command_info.py @@ -241,6 +241,8 @@ def _reference_view(expanded) -> list[dict]: "install-allowed" if reference.install_allowed else "discovery-only" ), "via": list(reference.via), + "verified": reference.verified, + "trust": _trust_level(reference.verified), } for reference in expanded.references ] @@ -248,6 +250,7 @@ def _reference_view(expanded) -> list[dict]: def _format_reference(reference) -> str: label = f"{reference.id} v{reference.version} ({reference.source_id}" + label += f"; {_trust_level(reference.verified)}" if reference.via: label += f"; via {' → '.join(reference.via)}" if not reference.install_allowed: diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py index f662b04989..9254869d12 100644 --- a/src/specify_cli/bundles/expansion.py +++ b/src/specify_cli/bundles/expansion.py @@ -18,6 +18,7 @@ class LoadedBundle: manifest: BundleManifest source_id: str install_allowed: bool + verified: bool = False class BundleLoader(Protocol): @@ -32,6 +33,7 @@ class ResolvedReference: install_allowed: bool via: tuple[str, ...] manifest: BundleManifest + verified: bool = False class SourceMismatchError(BundlerError): @@ -130,6 +132,7 @@ def register_reference( install_allowed=loaded.install_allowed, via=via, manifest=loaded.manifest, + verified=loaded.verified, ) index = self._reference_indexes.get(ref.id) if index is None: @@ -158,6 +161,7 @@ def shorten_cached_references( install_allowed=previous.install_allowed, via=via, manifest=previous.manifest, + verified=previous.verified, ) diff --git a/src/specify_cli/bundles/sources.py b/src/specify_cli/bundles/sources.py index 177472195d..50c9c0174c 100644 --- a/src/specify_cli/bundles/sources.py +++ b/src/specify_cli/bundles/sources.py @@ -138,6 +138,7 @@ def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: manifest, resolved.source.id, resolved.install_allowed, + resolved.entry.verified, ) diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 58143b6209..f9ebe9fe08 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -139,6 +139,8 @@ def fake_download(resolved, *, offline): "source": "local", "install_policy": "install-allowed", "via": [], + "verified": True, + "trust": "verified", }, { "id": "grandchild-bundle", @@ -146,6 +148,8 @@ def fake_download(resolved, *, offline): "source": "local", "install_policy": "install-allowed", "via": ["child-bundle"], + "verified": True, + "trust": "verified", }, ] component = next(c for c in payload["components"] if c["id"] == "child-extension") @@ -155,11 +159,60 @@ def fake_download(resolved, *, offline): assert transitive["origin"] == ["child-bundle", "grandchild-bundle"] text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) assert "References:" in text.output - assert " - child-bundle v1.0.0 (local)" in text.output - assert " - grandchild-bundle v1.0.0 (local; via child-bundle)" in text.output + assert " - child-bundle v1.0.0 (local; verified)" in text.output + assert " - grandchild-bundle v1.0.0 (local; verified; via child-bundle)" in text.output assert "overrides 1.0.0 from child-bundle" in text.output +def test_info_exposes_trust_of_unverified_reference(project: Path, monkeypatch): + parent_dir = project / "parent-bundle" + child_dir = project / "child-bundle" + parent_dir.mkdir() + child_dir.mkdir() + parent_path = parent_dir / "bundle.yml" + child_path = child_dir / "bundle.yml" + parent_data = reference_manifest_dict([{"id": "child-bundle", "version": "1.0.0"}]) + child_data = reference_manifest_dict( + [], + bundle_id="child-bundle", + extensions=[{"id": "child-extension", "version": "1.0.0"}], + ) + child_data["bundle"]["version"] = "1.0.0" + parent_path.write_text(yaml.safe_dump(parent_data), encoding="utf-8") + child_path.write_text(yaml.safe_dump(child_data), encoding="utf-8") + catalog = project / "trust-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict("demo-bundle", verified=True), + "child-bundle": catalog_entry_dict( + "child-bundle", + version="1.0.0", + download_url="https://example.com/child.zip", + verified=False, + ), + }, + ) + added = runner.invoke(app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]) + assert added.exit_code == 0, added.output + + def fake_download(resolved, *, offline): + path = {"demo-bundle": parent_path, "child-bundle": child_path}[resolved.entry.id] + return BundleManifest.from_file(path) + + monkeypatch.setattr("specify_cli.bundles.command_info._download_manifest", fake_download) + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert (payload["verified"], payload["trust"]) == (True, "verified") + (reference,) = payload["references"] + assert (reference["verified"], reference["trust"]) == (False, "community") + + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert "Trust: ✔ verified" in text.output + assert " - child-bundle v1.0.0 (local; community)" in text.output + + def test_format_component_describes_setting_overrides(): text = _format_component( { diff --git a/tests/specify_cli/bundles/test_command_install.py b/tests/specify_cli/bundles/test_command_install.py index 1738ff8aa5..c231c17ea5 100644 --- a/tests/specify_cli/bundles/test_command_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -166,7 +166,7 @@ def test_local_parent_installs_referenced_catalog_components( ) resolved = SimpleNamespace( - entry=SimpleNamespace(id="child-pack"), + entry=SimpleNamespace(id="child-pack", verified=True), source=SimpleNamespace(id="test-catalog"), install_allowed=True, ) @@ -206,7 +206,7 @@ def test_install_rejects_reference_source_mismatch(project: Path, monkeypatch): ), ) selected = SimpleNamespace( - entry=SimpleNamespace(id="child-pack"), + entry=SimpleNamespace(id="child-pack", verified=True), source=SimpleNamespace(id="winner"), install_allowed=True, ) @@ -242,12 +242,12 @@ def test_catalog_root_reference_install_builds_stack_once( child = BundleManifest.from_dict(child_data) selected = { "parent-pack": SimpleNamespace( - entry=SimpleNamespace(id="parent-pack"), + entry=SimpleNamespace(id="parent-pack", verified=True), source=SimpleNamespace(id="default"), install_allowed=True, ), "child-pack": SimpleNamespace( - entry=SimpleNamespace(id="child-pack"), + entry=SimpleNamespace(id="child-pack", verified=True), source=SimpleNamespace(id="default"), install_allowed=True, ), @@ -312,7 +312,7 @@ def test_reference_only_parent_installs_without_own_components( ), ) resolved = SimpleNamespace( - entry=SimpleNamespace(id="child-pack"), + entry=SimpleNamespace(id="child-pack", verified=True), source=SimpleNamespace(id="test-catalog"), install_allowed=True, ) @@ -354,7 +354,7 @@ def test_no_project_reference_gate_runs_before_init(tmp_path: Path, monkeypatch) ), ) resolved = SimpleNamespace( - entry=SimpleNamespace(id="child-pack"), + entry=SimpleNamespace(id="child-pack", verified=True), source=SimpleNamespace(id="test-catalog"), install_allowed=True, ) @@ -393,7 +393,7 @@ def test_discovery_only_reference_is_rejected_with_parent_chain( ), ) resolved = SimpleNamespace( - entry=SimpleNamespace(id="child-pack"), + entry=SimpleNamespace(id="child-pack", verified=True), source=SimpleNamespace(id="community"), install_allowed=False, ) @@ -421,7 +421,7 @@ def test_offline_reference_failure_names_reference_and_chain( ), ) resolved = SimpleNamespace( - entry=SimpleNamespace(id="child"), + entry=SimpleNamespace(id="child", verified=True), source=SimpleNamespace(id="default"), install_allowed=True, ) diff --git a/tests/specify_cli/bundles/test_command_update.py b/tests/specify_cli/bundles/test_command_update.py index d23f01741b..631f17106b 100644 --- a/tests/specify_cli/bundles/test_command_update.py +++ b/tests/specify_cli/bundles/test_command_update.py @@ -115,12 +115,12 @@ def test_update_refresh_removes_dropped_referenced_component( child = BundleManifest.from_dict(child_data) selected = { "parent": SimpleNamespace( - entry=SimpleNamespace(id="parent"), + entry=SimpleNamespace(id="parent", verified=True), source=SimpleNamespace(id="test-catalog"), install_allowed=True, ), "child": SimpleNamespace( - entry=SimpleNamespace(id="child"), + entry=SimpleNamespace(id="child", verified=True), source=SimpleNamespace(id="test-catalog"), install_allowed=True, ), @@ -177,8 +177,8 @@ def test_update_follows_parent_reference_pin_bump(project: Path, monkeypatch): ) child = BundleManifest.from_dict(child_data) selected = { - "parent": SimpleNamespace(entry=SimpleNamespace(id="parent"), source=SimpleNamespace(id="default"), install_allowed=True), - "child": SimpleNamespace(entry=SimpleNamespace(id="child"), source=SimpleNamespace(id="default"), install_allowed=True), + "parent": SimpleNamespace(entry=SimpleNamespace(id="parent", verified=True), source=SimpleNamespace(id="default"), install_allowed=True), + "child": SimpleNamespace(entry=SimpleNamespace(id="child", verified=True), source=SimpleNamespace(id="default"), install_allowed=True), } class Stack: @@ -244,8 +244,8 @@ def refresh(self, root, component): ) child = BundleManifest.from_dict(child_data) selected = { - "parent": SimpleNamespace(entry=SimpleNamespace(id="parent"), source=SimpleNamespace(id="default"), install_allowed=True), - "child": SimpleNamespace(entry=SimpleNamespace(id="child"), source=SimpleNamespace(id="default"), install_allowed=True), + "parent": SimpleNamespace(entry=SimpleNamespace(id="parent", verified=True), source=SimpleNamespace(id="default"), install_allowed=True), + "child": SimpleNamespace(entry=SimpleNamespace(id="child", verified=True), source=SimpleNamespace(id="default"), install_allowed=True), } class Stack: @@ -298,8 +298,8 @@ def test_update_rejects_discovery_only_reference(project: Path, monkeypatch): provides={"extensions": []}, )) selected = { - "parent": SimpleNamespace(entry=SimpleNamespace(id="parent"), source=SimpleNamespace(id="default"), install_allowed=True), - "child": SimpleNamespace(entry=SimpleNamespace(id="child"), source=SimpleNamespace(id="community"), install_allowed=False), + "parent": SimpleNamespace(entry=SimpleNamespace(id="parent", verified=True), source=SimpleNamespace(id="default"), install_allowed=True), + "child": SimpleNamespace(entry=SimpleNamespace(id="child", verified=True), source=SimpleNamespace(id="community"), install_allowed=False), } class Stack: @@ -329,7 +329,7 @@ def test_update_flat_plan_warnings_keep_existing_output(project: Path, monkeypat data["requires"]["tools"] = ["docker"] manifest = BundleManifest.from_dict(data) selected = SimpleNamespace( - entry=SimpleNamespace(id="flat"), source=SimpleNamespace(id="default"), + entry=SimpleNamespace(id="flat", verified=True), source=SimpleNamespace(id="default"), install_allowed=True, ) diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index 981cd490c7..3c8694a340 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -285,7 +285,7 @@ def test_validate_expands_references_and_checks_expanded_components(project: Pat ) path = project / "bundle.yml" path.write_text(yaml.safe_dump(parent), encoding="utf-8") - selected = SimpleNamespace(entry=SimpleNamespace(id="child", version="1.0.0"), source=SimpleNamespace(id="default"), install_allowed=True) + selected = SimpleNamespace(entry=SimpleNamespace(id="child", version="1.0.0", verified=True), source=SimpleNamespace(id="default"), install_allowed=True) class Stack: def resolve(self, bundle_id, version=None): @@ -324,7 +324,7 @@ def _write_reference_bundle(project: Path, references, *, bundle_id="parent", pr def _selected(bundle_id, *, install_allowed=True, source="default"): - return SimpleNamespace(entry=SimpleNamespace(id=bundle_id, version="1.0.0"), source=SimpleNamespace(id=source), install_allowed=install_allowed) + return SimpleNamespace(entry=SimpleNamespace(id=bundle_id, version="1.0.0", verified=True), source=SimpleNamespace(id=source), install_allowed=install_allowed) def test_validate_online_success(project: Path, monkeypatch): From 608ea3efb944f978c8724e3a27ff544443b8e59e Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 11:54:31 +0200 Subject: [PATCH 09/27] docs(bundles): clarify extension priority comparison rules Document that an omitted extension priority agrees with an explicit 10, and state that installed bundles never compare extension priority. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 5 ++++- src/specify_cli/bundles/installer.py | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 91bff34373..6d13286e79 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -150,7 +150,10 @@ bundle's own components, while preserving the existing extension, preset, step, and workflow grouping. A component is installed at most once per `kind:id`. Copies agree when their version and `source` match (omitted and set `source` values differ); extension and preset copies must also have the same `priority`, -and preset copies must also have the same `strategy`. +and preset copies must also have the same `strategy`. An omitted extension +`priority` means the default, `10`, so it agrees with an explicit `10`. A preset +`priority` is compared as written, so an omitted value differs from an explicit +`10`. Other differences between sibling references are conflicts that must be fixed in the parent. At every level, a bundle's own declaration wins over its references, takes the declaration's position, and emits a warning describing diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 1090796230..ff58d7b47b 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -330,9 +330,11 @@ def _check_installed_pins( for component in plan.components: key = component.kind, component.id for bundle_id, required in other_requirements.get(key, []): - # Cross-bundle sharing keeps the flat-bundle rule: an omitted - # extension priority already means the default, so only preset - # priority is compared here. + # Installed bundles keep the flat-bundle rule: extension priority + # is never compared, including explicit values such as 5 and 10. + # Only preset priority is. This differs from sibling references in + # one expansion, which compare extension priority after applying + # the default. different = component_setting_differences( component, required, extension_priority=False ) From 4d6084ce20a48a8c3ab38e34a59281423f7f5534 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 12:34:32 +0200 Subject: [PATCH 10/27] fix(bundles): treat blank reference source and version as unset A whitespace-only source was stripped to "" and then enforced as a pin, failing the plan with "requires source ''" after the loader skipped its own source check. Normalize blank source and version to None. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/bundles/command_install.py | 4 +++- src/specify_cli/bundles/manifest.py | 11 +++++++++-- tests/specify_cli/bundles/test_manifest.py | 11 +++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 6d13286e79..475dc50401 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -72,7 +72,7 @@ specify bundle install | `--refresh` | Refresh owned components from the supplied bundle source | | `--version` | Install an exact historical or current catalog release | -Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack. +Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack, unless the manifest declares `provides.bundles`. A local parent with bundle references resolves those children through the catalog stack at command time, so it needs catalog and network access (see [Bundle references](#bundle-references)). If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped, as are bundle-owned compatible packaged patch releases with a warning (see below). Other differing or missing installed versions cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. diff --git a/src/specify_cli/bundles/command_install.py b/src/specify_cli/bundles/command_install.py index 58bf01aee1..335e350095 100644 --- a/src/specify_cli/bundles/command_install.py +++ b/src/specify_cli/bundles/command_install.py @@ -54,7 +54,9 @@ def bundle_install( ``bundle_id`` may be a catalog bundle id, or a local path to a built artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local - sources install directly without consulting the catalog stack. Use + sources install directly without consulting the catalog stack, unless + the manifest declares ``provides.bundles``: a local parent's references + resolve through the catalog stack and need network access. Use ``--refresh`` to update owned components from a newer local source. """ try: diff --git a/src/specify_cli/bundles/manifest.py b/src/specify_cli/bundles/manifest.py index edfc04cc8f..4476cb5856 100644 --- a/src/specify_cli/bundles/manifest.py +++ b/src/specify_cli/bundles/manifest.py @@ -358,13 +358,20 @@ def _parse_bundle_refs(raw: Any) -> list[BundleRef]: refs.append( BundleRef( id=_text(item.get("id")), - version=(str(item["version"]).strip() if item.get("version") else None), - source=(str(item["source"]).strip() if item.get("source") else None), + version=_optional_text(item.get("version")), + source=_optional_text(item.get("source")), ) ) return refs +def _optional_text(raw: Any) -> str | None: + """Stripped text, with unset, empty and whitespace-only values as ``None``.""" + if not raw: + return None + return str(raw).strip() or None + + def _parse_priority(kind: str, raw: Any) -> int | None: if raw is None: return None diff --git a/tests/specify_cli/bundles/test_manifest.py b/tests/specify_cli/bundles/test_manifest.py index 8306e8fc1c..6341cc92f4 100644 --- a/tests/specify_cli/bundles/test_manifest.py +++ b/tests/specify_cli/bundles/test_manifest.py @@ -59,6 +59,17 @@ def test_reference_values_are_stripped_and_version_coerced_to_text(): assert manifest.bundles == [BundleRef("bugfix", "1.0.1", "default")] +@pytest.mark.parametrize("blank", [None, "", " ", "\t "]) +def test_blank_reference_source_and_version_are_unset(blank): + manifest = _manifest( + reference_manifest_dict( + [{"id": "bugfix", "version": blank, "source": blank}] + ) + ) + + assert manifest.bundles == [BundleRef("bugfix", None, None)] + + def test_components_stay_own_only_when_bundles_declared(): manifest = _manifest( reference_manifest_dict( From 51ccfc35a60bb1faf89ba63c5f250bfac2e8ccf0 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 13:59:07 +0200 Subject: [PATCH 11/27] fix(bundles): keep not-found details for offline reference lookups Under --offline, a referenced bundle missing from the catalogs replaced the real not-found message with advice to go online, which cannot help a typo or a local file catalog. Keep the original message and add the retry hint only when a built-in catalog snapshot is configured. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/sources.py | 12 +++++++---- .../bundles/test_command_install.py | 5 +++-- tests/specify_cli/bundles/test_sources.py | 21 +++++++++++++++++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/bundles/sources.py b/src/specify_cli/bundles/sources.py index 50c9c0174c..5bcd088b6c 100644 --- a/src/specify_cli/bundles/sources.py +++ b/src/specify_cli/bundles/sources.py @@ -115,11 +115,15 @@ def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: try: resolved = self._stack.resolve(ref.id, ref.version) except BundleNotFoundError as exc: - if self._offline: + # A packaged built-in snapshot can lag the live catalog, so only + # then can going online help; otherwise keep the plain not-found. + if self._offline and any( + source.url.startswith("builtin://") for source in self._stack.sources + ): raise BundlerError( - f"Network access disabled; cannot resolve bundle reference " - f"{ref.id}@{ref.version or ''} while offline. " - "Re-run without --offline." + f"{exc} Network access disabled; a built-in catalog may be " + f"outdated, so {ref.id}@{ref.version or ''} may " + "resolve online. Re-run without --offline." ) from exc raise except ReleaseNotFoundError as exc: diff --git a/tests/specify_cli/bundles/test_command_install.py b/tests/specify_cli/bundles/test_command_install.py index c231c17ea5..15d522962e 100644 --- a/tests/specify_cli/bundles/test_command_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -456,8 +456,9 @@ def test_local_reference_bundle_offline_names_reference_and_network_limit(projec assert result.exit_code == 1, result.output output = " ".join(result.output.split()) assert "Cannot resolve bundle reference missing-child@1.0.0 (parent)" in output - assert "Network access disabled" in output - assert "not found in any configured catalog" not in output + assert "Bundle 'missing-child' was not found in any configured catalog." in output + assert "built-in catalog may be outdated" in output + assert "Re-run without --offline." in output def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): diff --git a/tests/specify_cli/bundles/test_sources.py b/tests/specify_cli/bundles/test_sources.py index 75ab22a5ba..45ec4b2a72 100644 --- a/tests/specify_cli/bundles/test_sources.py +++ b/tests/specify_cli/bundles/test_sources.py @@ -3,6 +3,7 @@ import hashlib import io import zipfile +from dataclasses import replace from pathlib import Path from types import SimpleNamespace @@ -392,14 +393,30 @@ def test_catalog_loader_offline_missing_bundle_reports_network_limit(): {"default": catalog_payload({})}, ) + with pytest.raises(BundlerError) as excinfo: + CatalogBundleLoader(stack, offline=True).load( + BundleRef("missing", "0.9.0"), ("team",) + ) + + message = str(excinfo.value) + assert message.startswith( + "Bundle 'missing' was not found in any configured catalog." + ) + assert "built-in catalog may be outdated" in message + assert "Re-run without --offline." in message + + +def test_catalog_loader_offline_missing_bundle_in_local_catalog_has_no_retry_hint(): + local = replace(_catalog_source("team", 1), url="/tmp/team-catalog.json") + stack = _catalog_stack([local], {"team": catalog_payload({})}) + with pytest.raises(BundlerError) as excinfo: CatalogBundleLoader(stack, offline=True).load( BundleRef("missing", "0.9.0"), ("team",) ) assert str(excinfo.value) == ( - "Network access disabled; cannot resolve bundle reference missing@0.9.0 " - "while offline. Re-run without --offline." + "Bundle 'missing' was not found in any configured catalog." ) From bed402c1a9e3f7c5b0b7055a4d29f2a83689d2c5 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 14:30:25 +0200 Subject: [PATCH 12/27] fix(bundles): label equivalent override history as historical An override entry equal to the final declaration still printed as "overrides ", implying a difference. Keep the entry but label it "also declared by ... (same as final)". Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/command_info.py | 11 ++++------- tests/specify_cli/bundles/test_command_info.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py index d50d4f48f8..0848b9323b 100644 --- a/src/specify_cli/bundles/command_info.py +++ b/src/specify_cli/bundles/command_info.py @@ -305,11 +305,8 @@ def _format_override(item: dict, override: dict) -> str: value = override.get(field) changed.append(f"{field}={value if value is not None else ''}") - # An override always differs in at least one install-affecting field, but - # retain the historical version-only marker if a caller supplies an - # incomplete provenance dictionary. - if not changed: - changed.append(str(old_version) if old_version else "unpinned") - marker = ", ".join(changed) origin = " → ".join(override.get("origin", [])) - return f"overrides {marker} from {origin}" + if not changed: + # Equivalent copies stay in the history; don't invent a difference. + return f"also declared by {origin} (same as final)" + return f"overrides {', '.join(changed)} from {origin}" diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index f9ebe9fe08..854b29f2a9 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -213,6 +213,24 @@ def fake_download(resolved, *, offline): assert " - child-bundle v1.0.0 (local; community)" in text.output +def test_format_component_equivalent_override_is_labelled_historical(): + text = _format_component( + { + "kind": "extensions", + "id": "e", + "version": "1.0.0", + "overrides": [ + {"version": "1.0.0", "origin": ["child", "leaf"]}, + {"version": "2.0.0", "origin": ["child"]}, + ], + } + ) + + assert "also declared by child → leaf (same as final)" in text + assert "overrides 1.0.0" not in text + assert "overrides 2.0.0 from child" in text + + def test_format_component_describes_setting_overrides(): text = _format_component( { From d0f19c07e199238d70748a8ecde710ab41a5820d Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 14:39:11 +0200 Subject: [PATCH 13/27] test(bundles): patch both _build_stack namespaces in flat install test _expand_manifest resolves _build_stack from _commands, so patching only command_install's alias missed stack construction inside the helper. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/specify_cli/bundles/test_command_install.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/specify_cli/bundles/test_command_install.py b/tests/specify_cli/bundles/test_command_install.py index 15d522962e..fcf4a028e4 100644 --- a/tests/specify_cli/bundles/test_command_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -280,11 +280,12 @@ def build_stack(*args, **kwargs): def test_flat_local_install_never_builds_catalog_stack(project: Path, monkeypatch): bundle = write_manifest(project / "flat", valid_manifest_dict(provides={})) - monkeypatch.setattr( - command_install, - "_build_stack", - lambda *args, **kwargs: pytest.fail("flat local install built a catalog stack"), - ) + def fail(*args, **kwargs): + pytest.fail("flat local install built a catalog stack") + + # Both namespaces: command_install's alias and the one _expand_manifest uses. + monkeypatch.setattr(command_install, "_build_stack", fail) + monkeypatch.setattr(_commands, "_build_stack", fail) result = runner.invoke(app, ["bundle", "install", str(bundle), "--offline"]) From 5f84ffb0b3f3fc2757ca1032972677a9ef26fc94 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 15:20:22 +0200 Subject: [PATCH 14/27] docs(bundles): state that presets must declare an integer priority Only extensions may omit priority (default 10). Drop the sentence that described an omitted preset priority as a comparable setting. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 475dc50401..da64f72553 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -150,10 +150,9 @@ bundle's own components, while preserving the existing extension, preset, step, and workflow grouping. A component is installed at most once per `kind:id`. Copies agree when their version and `source` match (omitted and set `source` values differ); extension and preset copies must also have the same `priority`, -and preset copies must also have the same `strategy`. An omitted extension -`priority` means the default, `10`, so it agrees with an explicit `10`. A preset -`priority` is compared as written, so an omitted value differs from an explicit -`10`. +and preset copies must also have the same `strategy`. Extensions may omit +`priority`; an omitted value means the default, `10`, so it agrees with an +explicit `10`. Presets must declare an integer `priority`. Other differences between sibling references are conflicts that must be fixed in the parent. At every level, a bundle's own declaration wins over its references, takes the declaration's position, and emits a warning describing From 757e98d0620c6afe83cfbfa7496efb5c29480f0e Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 15:58:54 +0200 Subject: [PATCH 15/27] docs(bundles): clarify component JSON source, origin and overrides Document that component `source` appears only for bundles with references, and that `overrides` does not require `origin`. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index da64f72553..31dcbb2aae 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -55,7 +55,7 @@ specify bundle info --versions | `--json` | Emit machine-readable JSON | | `--versions` | List the current and historical catalog releases | -Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary, with each referenced bundle's own trust level (`verified` or `community`), and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, `via`, and the referenced bundle's own `verified` and `trust`; referenced component entries add `origin` and, when applicable, `overrides`. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. +Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary, with each referenced bundle's own trust level (`verified` or `community`), and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, `via`, and the referenced bundle's own `verified` and `trust`; for bundles with references, component entries also include `source` when the manifest declares one (flat bundles omit it), `origin` (the chain of bundles that contributed the component) when it comes from a referenced bundle, and `overrides` whenever the entry replaced other declarations, including a parent's own declaration, which has no `origin`. Each `overrides` item carries the replaced `version` and `origin`, plus any `source`, `priority`, or `strategy`. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. Use `specify bundle info --versions` to inspect the catalog history without downloading a bundle manifest. With `--json`, it emits the bundle ID, available versions, current version, source, and install policy. From 13ce94438040efd36cc2231ed201a1df2270bdc9 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 16:44:58 +0200 Subject: [PATCH 16/27] fix(bundles): keep every declaring bundle when merging agreeing copies Agreeing siblings were merged into one entry that kept a single origin, so a later parent override named only one of them. Entries now carry a record per distinct declaring bundle, merged by one shared rule (_merge_declarations) that also governs override history, and one function (_declare_own) builds an own-declaration entry and its notice. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/expansion.py | 145 +++++++++++--------- tests/specify_cli/bundles/test_expansion.py | 68 +++++++++ 2 files changed, 149 insertions(+), 64 deletions(-) diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py index 9254869d12..b55e934264 100644 --- a/src/specify_cli/bundles/expansion.py +++ b/src/specify_cli/bundles/expansion.py @@ -92,15 +92,25 @@ def has_references(self) -> bool: @dataclass(frozen=True) class _Entry: component: ComponentRef - origin: tuple[str, ...] + # One record per distinct declaring bundle whose copy agrees with + # ``component``; the route to each is relative to the current bundle. + declarations: tuple[ComponentOverride, ...] overrides: tuple[ComponentOverride, ...] = () + @property + def origin(self) -> tuple[str, ...]: + """Shortest route to a declaring bundle, for display.""" + return min( + (declaration.origin for declaration in self.declarations), + key=lambda origin: (len(origin), origin), + ) + @dataclass(frozen=True) class _OverrideNotice: component: ComponentRef declaring: tuple[str, ...] - replaced: tuple[_Entry, ...] + replaced: tuple[ComponentOverride, ...] @dataclass(frozen=True) @@ -301,36 +311,16 @@ def _expand( # Apply the current bundle's own declarations after all referenced plans. for component in manifest.components: key = component.kind, component.id - own_entry = _Entry(component, ()) existing = candidates.get(key, []) - if not existing: - candidates[key] = [own_entry] - ordered.append(key) - continue - - replaced: list[_Entry] = [] - inherited_overrides: list[ComponentOverride] = [] - for entry in existing: - inherited_overrides.extend(entry.overrides) - if _component_differences(entry.component, component): - replaced.append(entry) - # Removing and re-adding gives an override the position of the own - # declaration, rather than the first referenced occurrence. - ordered.remove(key) - inherited = _merge_overrides(inherited_overrides) - candidates[key] = [_Entry(component, (), inherited)] + entry, notice = _declare_own(component, existing) + if existing: + # Removing and re-adding gives an override the position of the own + # declaration, rather than the first referenced occurrence. + ordered.remove(key) + candidates[key] = [entry] ordered.append(key) - if replaced: - direct_overrides = tuple( - ComponentOverride(entry.component, entry.origin) for entry in replaced - ) - candidates[key] = [ - _Entry(component, (), _merge_overrides(inherited, direct_overrides)) - ] - _append_notice( - notices, - _OverrideNotice(component, (), tuple(replaced)), - ) + if notice is not None: + _append_notice(notices, notice) if conflicts: raise BundlerError("Bundle component conflicts:\n" + "\n".join(conflicts)) @@ -374,21 +364,18 @@ def _merge_candidate( conflicts.append(_format_conflict(previous, entry, declaring_chain)) return - # A shared dependency can arrive through multiple routes. Merge an - # agreeing copy into the existing candidate instead of retaining a - # duplicate override entry. Preserve the first component spelling and the - # shorter provenance, while keeping distinct override histories from both - # routes for `bundle info`. + # A shared dependency can arrive through multiple routes, and distinct + # bundles can declare the same component. Merge an agreeing copy into the + # existing candidate, keeping each distinct declaring bundle so a later + # override can name all of them. Preserve the first component spelling. for index, fields in enumerate(differences): if not fields: previous = existing[index] - overrides = _merge_overrides(previous.overrides, entry.overrides) - origin = ( - entry.origin - if len(entry.origin) < len(previous.origin) - else previous.origin + existing[index] = _Entry( + previous.component, + _merge_declarations(previous.declarations, entry.declarations), + _merge_declarations(previous.overrides, entry.overrides), ) - existing[index] = _Entry(previous.component, origin, overrides) return if key in own_keys: @@ -396,42 +383,75 @@ def _merge_candidate( return -def _merge_overrides( - *histories: tuple[ComponentOverride, ...] | list[ComponentOverride], +def _merge_declarations( + *groups: tuple[ComponentOverride, ...] | list[ComponentOverride], ) -> tuple[ComponentOverride, ...]: - """Merge override histories to one record per discarded declaration. - - The declaration is identified by the discarded component and the bundle - that declared it; the route to it is display-only, so only the shortest - chain is kept. Without this, every route through a shared graph would add - its own copy and the history would grow exponentially. + """Merge declaration records to one per component and declaring bundle. + + This is the single rule for what counts as one declaration: the component + and the bundle that declared it. The route to it is display-only, so only + the shortest chain is kept, with ties broken on the chain itself so the + result does not depend on merge order. Routes to the same declaration + collapse (otherwise a shared graph grows exponentially), while distinct + declaring bundles stay separate records. It serves both the declarations + behind an entry and the override history discarded by a parent. """ - best: dict[tuple[ComponentRef, str], ComponentOverride] = {} - for history in histories: - for override in history: - key = (override.component, override.origin[-1]) + best: dict[tuple[ComponentRef, tuple[str, ...]], ComponentOverride] = {} + for group in groups: + for declaration in group: + key = (declaration.component, declaration.origin[-1:]) current = best.get(key) - if current is None or (len(override.origin), override.origin) < ( + if current is None or (len(declaration.origin), declaration.origin) < ( len(current.origin), current.origin, ): - best[key] = override + best[key] = declaration return tuple(best.values()) +def _declare_own( + component: ComponentRef, existing: list[_Entry] +) -> tuple[_Entry, _OverrideNotice | None]: + """Entry for a bundle's own declaration over any referenced copies. + + Every declaration of a differing referenced copy is discarded and recorded, + one per declaring bundle. Agreeing copies are not overrides. + """ + inherited = _merge_declarations(*(entry.overrides for entry in existing)) + replaced = tuple( + declaration + for entry in existing + if _component_differences(entry.component, component) + for declaration in entry.declarations + ) + entry = _Entry( + component, + (ComponentOverride(component, ()),), + _merge_declarations(inherited, replaced), + ) + notice = _OverrideNotice(component, (), replaced) if replaced else None + return entry, notice + + def _stable_keys(ordered: list[tuple[str, str]]) -> list[tuple[str, str]]: kind_order = {kind: index for index, kind in enumerate(COMPONENT_KINDS)} return sorted(ordered, key=lambda key: kind_order.get(key[0], len(kind_order))) +def _prefix_declarations( + declarations: tuple[ComponentOverride, ...], prefix: tuple[str, ...] +) -> tuple[ComponentOverride, ...]: + return tuple( + ComponentOverride(declaration.component, prefix + declaration.origin) + for declaration in declarations + ) + + def _prefix_entry(entry: _Entry, prefix: tuple[str, ...]) -> _Entry: return _Entry( entry.component, - prefix + entry.origin, - tuple( - ComponentOverride(override.component, prefix + override.origin) - for override in entry.overrides - ), + _prefix_declarations(entry.declarations, prefix), + _prefix_declarations(entry.overrides, prefix), ) @@ -439,10 +459,7 @@ def _prefix_notice(notice: _OverrideNotice, prefix: tuple[str, ...]) -> _Overrid return _OverrideNotice( notice.component, prefix + notice.declaring, - tuple( - _Entry(entry.component, prefix + entry.origin, entry.overrides) - for entry in notice.replaced - ), + _prefix_declarations(notice.replaced, prefix), ) diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py index 4f5d6db2dd..a5ed05f0ed 100644 --- a/tests/specify_cli/bundles/test_expansion.py +++ b/tests/specify_cli/bundles/test_expansion.py @@ -1,6 +1,8 @@ """Deterministic tests for bundle-reference expansion.""" from __future__ import annotations +import re + import pytest from specify_cli.bundles import BundlerError @@ -814,3 +816,69 @@ def test_sibling_extension_priority_non_default_still_conflicts_with_omitted(): with pytest.raises(BundlerError, match="priority"): expand_manifest(root, FakeBundleLoader(loaded(first, second))) + + +def _agreeing_siblings_with_parent_override(order): + a = manifest("a", extensions=[{"id": "e", "version": "1.0.0"}]) + b = manifest("b", extensions=[{"id": "e", "version": "1.0.0"}]) + refs = [{"id": bundle_id, "version": "1.0.0"} for bundle_id in order] + root = manifest("root", bundles=refs, extensions=[{"id": "e", "version": "2.0.0"}]) + return expand_manifest(root, FakeBundleLoader(loaded(a, b))) + + +@pytest.mark.parametrize("order", [("a", "b"), ("b", "a")]) +def test_agreeing_siblings_are_both_recorded_when_parent_overrides(order): + expanded = _agreeing_siblings_with_parent_override(order) + + overrides = expanded.origins[("extensions", "e")].overrides + assert sorted(item.origin for item in overrides) == [("a",), ("b",)] + (warning,) = expanded.warnings + assert "from root → a" in warning + assert "from root → b" in warning + + +def test_agreeing_siblings_result_does_not_depend_on_order(): + forward = _agreeing_siblings_with_parent_override(("a", "b")) + reverse = _agreeing_siblings_with_parent_override(("b", "a")) + + def summary(expanded): + overrides = expanded.origins[("extensions", "e")].overrides + chains = re.findall(r"from (root → \w+)", expanded.warnings[0]) + return sorted(overrides, key=str), sorted(chains) + + assert summary(forward) == summary(reverse) + + +def test_agreeing_siblings_declarations_survive_nested_routes(): + a = manifest("a", extensions=[{"id": "e", "version": "1.0.0"}]) + b = manifest("b", extensions=[{"id": "e", "version": "1.0.0"}]) + mid = manifest( + "mid", + bundles=[{"id": "a", "version": "1.0.0"}, {"id": "b", "version": "1.0.0"}], + ) + root = manifest( + "root", + bundles=[{"id": "mid", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + + expanded = expand_manifest(root, FakeBundleLoader(loaded(a, b, mid))) + + overrides = expanded.origins[("extensions", "e")].overrides + assert sorted(item.origin for item in overrides) == [("mid", "a"), ("mid", "b")] + + +def test_two_routes_to_one_declaring_bundle_stay_one_record(): + shared = manifest("shared", extensions=[{"id": "e", "version": "1.0.0"}]) + left = manifest("left", bundles=[{"id": "shared", "version": "1.0.0"}]) + right = manifest("right", bundles=[{"id": "shared", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[{"id": "left", "version": "1.0.0"}, {"id": "right", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + + expanded = expand_manifest(root, FakeBundleLoader(loaded(shared, left, right))) + + overrides = expanded.origins[("extensions", "e")].overrides + assert [item.origin for item in overrides] == [("left", "shared")] From aa414a1a34bed69155d59cb1d0cc97fc88137994 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Fri, 23 Oct 2026 18:01:16 +0200 Subject: [PATCH 17/27] fix(bundles): recommend online retry for missing offline builtin releases Offline, a pin missing from the built-in snapshot may still resolve online, so advise re-running without --offline instead of updating the reference. The advice depends on the winning source. CatalogSource.is_builtin is now the single definition of a built-in source, shared with the adapter dispatch. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/adapters.py | 2 +- src/specify_cli/bundles/catalogs.py | 10 ++- src/specify_cli/bundles/sources.py | 47 ++++++++++--- .../specify_cli/bundles/test_catalog_stack.py | 15 ++++ tests/specify_cli/bundles/test_sources.py | 70 ++++++++++++++++++- 5 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/bundles/adapters.py b/src/specify_cli/bundles/adapters.py index 46433cb005..3f1bc414cb 100644 --- a/src/specify_cli/bundles/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -159,7 +159,7 @@ def fetch(source: CatalogSource) -> dict: ) from None scheme = parsed.scheme.lower() - if scheme == "builtin": + if source.is_builtin: repository_url = _BUILTIN_REPOSITORY_URLS.get(url) if repository_url is None: raise BundlerError(f"Unknown built-in catalog '{url}'.") diff --git a/src/specify_cli/bundles/catalogs.py b/src/specify_cli/bundles/catalogs.py index fb6e896b38..7220d99009 100644 --- a/src/specify_cli/bundles/catalogs.py +++ b/src/specify_cli/bundles/catalogs.py @@ -10,7 +10,7 @@ from enum import Enum from pathlib import Path from typing import Any - +from urllib.parse import urlparse from . import BundlerError from .manifest import _text from .yamlio import ensure_within, load_yaml @@ -67,6 +67,14 @@ class CatalogSource: def install_allowed(self) -> bool: return self.install_policy is InstallPolicy.INSTALL_ALLOWED + @property + def is_builtin(self) -> bool: + """Whether this source is a ``builtin://`` catalog (packaged snapshot offline).""" + try: + return urlparse(self.url).scheme.lower() == "builtin" + except ValueError: + return False + @classmethod def from_dict(cls, data: Any, scope: Scope) -> "CatalogSource": if not isinstance(data, dict): diff --git a/src/specify_cli/bundles/sources.py b/src/specify_cli/bundles/sources.py index 5bcd088b6c..773f4ac15a 100644 --- a/src/specify_cli/bundles/sources.py +++ b/src/specify_cli/bundles/sources.py @@ -104,6 +104,23 @@ def __init__(self, stack, *, offline: bool, download=None) -> None: self._offline = offline self._download = download + def _offline_snapshot_may_lag(self, source_id: str | None = None) -> bool: + """Whether going online could change a miss. + + Offline, a built-in catalog is the packaged snapshot, which can lag the + live catalog. With *source_id*, only that source counts (the winner of a + release lookup); otherwise any configured source does (a missing + bundle has no winner). Matching goes through the source, never its id, + so a project catalog named ``default`` is not mistaken for the built-in. + """ + if not self._offline: + return False + return any( + source.is_builtin + for source in self._stack.sources + if source_id is None or source.id == source_id + ) + def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: """Resolve and download the exact release named by *ref*. @@ -115,11 +132,7 @@ def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: try: resolved = self._stack.resolve(ref.id, ref.version) except BundleNotFoundError as exc: - # A packaged built-in snapshot can lag the live catalog, so only - # then can going online help; otherwise keep the plain not-found. - if self._offline and any( - source.url.startswith("builtin://") for source in self._stack.sources - ): + if self._offline_snapshot_may_lag(): raise BundlerError( f"{exc} Network access disabled; a built-in catalog may be " f"outdated, so {ref.id}@{ref.version or ''} may " @@ -127,7 +140,12 @@ def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: ) from exc raise except ReleaseNotFoundError as exc: - missing = _missing_release_error(ref, chain, exc) + missing = _missing_release_error( + ref, + chain, + exc, + snapshot_may_lag=self._offline_snapshot_may_lag(exc.source_id), + ) if missing is not None: raise missing from exc raise @@ -146,22 +164,31 @@ def load(self, ref: BundleRef, chain: tuple[str, ...]) -> LoadedBundle: ) -def _missing_release_error(ref, chain, error: ReleaseNotFoundError): +def _missing_release_error( + ref, chain, error: ReleaseNotFoundError, *, snapshot_may_lag: bool = False +): """Format the catalog stack's exact-release failure for bundle authors. ``CatalogStack.resolve`` provides the advertised versions and winning source as structured exception attributes, so this translation does not depend on - the wording of its diagnostic message. + the wording of its diagnostic message. When *snapshot_may_lag* is set, the + winner is an offline packaged snapshot, so the pin may be valid online and + the advice is to retry rather than to change the reference. """ if ref.version is None: return None parent = chain[-1] if chain else "" offered = ", ".join(error.advertised) + advice = ( + "Network access disabled; the built-in catalog may be outdated, so " + "the pin may resolve online. Re-run without --offline." + if snapshot_may_lag + else "Update the reference to an advertised version." + ) return BundlerError( f"'{parent}' pins {error.bundle_id} {error.version}, but catalog " - f"'{error.source_id}' offers {offered}. Update the reference to an " - "advertised version." + f"'{error.source_id}' offers {offered}. {advice}" ) diff --git a/tests/specify_cli/bundles/test_catalog_stack.py b/tests/specify_cli/bundles/test_catalog_stack.py index 2082593107..2553f74fef 100644 --- a/tests/specify_cli/bundles/test_catalog_stack.py +++ b/tests/specify_cli/bundles/test_catalog_stack.py @@ -298,3 +298,18 @@ def fetcher(src): stack = CatalogStack([_source("bad", 1, "install-allowed")], fetcher) with pytest.raises(BundlerError, match="bad"): stack.resolve("anything") + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("builtin://default", True), + ("BUILTIN://community", True), + ("https://example.com/catalog.json", False), + ("file:///tmp/catalog.json", False), + ("/tmp/builtin://catalog.json", False), + ("https://[::1/catalog.json", False), + ], +) +def test_catalog_source_is_builtin_follows_url_scheme(url, expected): + assert _source("x", 1, "install-allowed", url=url).is_builtin is expected diff --git a/tests/specify_cli/bundles/test_sources.py b/tests/specify_cli/bundles/test_sources.py index 45ec4b2a72..342ce415ff 100644 --- a/tests/specify_cli/bundles/test_sources.py +++ b/tests/specify_cli/bundles/test_sources.py @@ -289,7 +289,7 @@ def test_catalog_loader_expansion_wraps_missing_release_and_source_failures(): with pytest.raises(BundlerError) as excinfo: expand_manifest( root, - CatalogBundleLoader(stack, offline=True, download=lambda *_args, **_kwargs: _manifest("bugfix")), + CatalogBundleLoader(stack, offline=False, download=lambda *_args, **_kwargs: _manifest("bugfix")), ) message = str(excinfo.value) @@ -382,11 +382,77 @@ def test_catalog_loader_missing_release_names_pin_and_offered_versions(): r"2\.0\.0, 1\.0\.0\. Update the reference" ), ): - CatalogBundleLoader(stack, offline=True).load( + CatalogBundleLoader(stack, offline=False).load( BundleRef("bugfix", "0.9.0"), ("team",) ) +def _missing_release_stack(source): + return _catalog_stack( + [source], + { + source.id: catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.0.1")} + ) + }, + ) + + +def test_catalog_loader_offline_builtin_missing_release_recommends_retry(): + stack = _missing_release_stack(_catalog_source("default", 1)) + + with pytest.raises(BundlerError) as excinfo: + CatalogBundleLoader(stack, offline=True).load( + BundleRef("bugfix", "1.0.2"), ("team",) + ) + + message = str(excinfo.value) + assert "'team' pins bugfix 1.0.2, but catalog 'default' offers 1.0.1." in message + assert "built-in catalog may be outdated" in message + assert "Re-run without --offline." in message + assert "Update the reference" not in message + + +@pytest.mark.parametrize("offline", [True, False]) +def test_catalog_loader_non_builtin_missing_release_keeps_update_advice(offline): + # A local catalog named "default" must not be mistaken for the built-in one. + local = replace(_catalog_source("default", 1), url="/tmp/default-catalog.json") + stack = _missing_release_stack(local) + + with pytest.raises(BundlerError) as excinfo: + CatalogBundleLoader(stack, offline=offline).load( + BundleRef("bugfix", "1.0.2"), ("team",) + ) + + message = str(excinfo.value) + assert "Update the reference to an advertised version." in message + assert "--offline" not in message + + +def test_catalog_loader_offline_release_miss_depends_on_winning_source(): + # The built-in catalog is configured, but a local source wins the lookup. + local = replace(_catalog_source("team", 1), url="/tmp/team-catalog.json") + builtin = _catalog_source("default", 20) + stack = _catalog_stack( + [local, builtin], + { + "team": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.0.1")} + ), + "default": catalog_payload( + {"bugfix": _resolved_catalog_entry(version="1.0.2")} + ), + }, + ) + + with pytest.raises(BundlerError) as excinfo: + CatalogBundleLoader(stack, offline=True).load( + BundleRef("bugfix", "1.0.2"), ("team",) + ) + + assert "Update the reference to an advertised version." in str(excinfo.value) + + def test_catalog_loader_offline_missing_bundle_reports_network_limit(): stack = _catalog_stack( [_catalog_source("default", 1)], From bb1939856cbe5c199c3ca9a32936dd38dc0eaa46 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 08:20:15 +0200 Subject: [PATCH 18/27] fix(bundles): keep agreeing declarations behind a bundle's own entry When a bundle declared a component that its references also declared, its entry kept only its own record. An ancestor overriding that component then named the intermediate bundle but dropped the deeper agreeing declarations (root -> mid -> leaf). _declare_own now carries every agreeing referenced declaration onto its entry; differing ones stay in the override history. _merge_candidate and _declare_own now build entries through _entry_for, which applies the one-record-per-declaring-bundle rule, and the version-plus-settings comparison moves from expansion into conflict.component_differences and same_requirement as the single equality rule. Component ordering is built from reference-derived and own keys directly; the resulting order is unchanged. Assisted-by: Antigravity CLI (model: Gemini 3.8 Flash, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/conflict.py | 32 +++++ src/specify_cli/bundles/expansion.py | 123 +++++++++-------- tests/specify_cli/bundles/test_conflict.py | 21 ++- tests/specify_cli/bundles/test_expansion.py | 141 +++++++++++++++++--- 4 files changed, 236 insertions(+), 81 deletions(-) diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index e23f40992e..e24c6a5ea5 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -59,6 +59,38 @@ def component_setting_differences( ] +def component_differences( + a: ComponentRef, b: ComponentRef, *, extension_priority: bool = True +) -> list[str]: + """Install-affecting differences between two refs of the same kind:id. + + Version is compared through ``same_version`` (``v1.0.0`` == ``1.0.0``); + ``source``, ``priority`` and ``strategy`` through ``effective_setting``. + An empty list means the two refs install identically. + """ + different: list[str] = [] + if a.version is None or b.version is None: + if a.version != b.version: + different.append("version") + elif not same_version(a.version, b.version): + different.append("version") + different.extend( + component_setting_differences(a, b, extension_priority=extension_priority) + ) + return different + + +def same_requirement( + a: ComponentRef, b: ComponentRef, *, extension_priority: bool = True +) -> bool: + """True when *a* and *b* describe the same installable requirement.""" + return ( + a.kind == b.kind + and a.id == b.id + and not component_differences(a, b, extension_priority=extension_priority) + ) + + def detect_conflicts( manifest: BundleManifest, active_integration: str | None, diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py index b55e934264..e361169c3c 100644 --- a/src/specify_cli/bundles/expansion.py +++ b/src/specify_cli/bundles/expansion.py @@ -2,10 +2,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol +from typing import Iterable, Protocol from . import BundlerError -from .conflict import component_setting_differences +from .conflict import component_differences from .manifest import COMPONENT_KINDS, BundleManifest, BundleRef, ComponentRef from .versioning import same_version @@ -206,7 +206,7 @@ def _expand( return cached candidates: dict[tuple[str, str], list[_Entry]] = {} - ordered: list[tuple[str, str]] = [] + reference_keys: list[tuple[str, str]] = [] reference_paths: dict[str, tuple[str, ...]] = {} notices: list[_OverrideNotice] = [] conflicts: list[str] = [] @@ -300,7 +300,7 @@ def _expand( _merge_candidate( entry, candidates, - ordered, + reference_keys, own_keys, conflicts, chain, @@ -309,22 +309,19 @@ def _expand( _append_notice(notices, _prefix_notice(notice, child_prefix)) # Apply the current bundle's own declarations after all referenced plans. + own_manifest_keys = [(c.kind, c.id) for c in manifest.components] for component in manifest.components: key = component.kind, component.id existing = candidates.get(key, []) entry, notice = _declare_own(component, existing) - if existing: - # Removing and re-adding gives an override the position of the own - # declaration, rather than the first referenced occurrence. - ordered.remove(key) candidates[key] = [entry] - ordered.append(key) if notice is not None: _append_notice(notices, notice) if conflicts: raise BundlerError("Bundle component conflicts:\n" + "\n".join(conflicts)) + ordered = [k for k in reference_keys if k not in own_keys] + own_manifest_keys entries = tuple(candidates[key][0] for key in _stable_keys(ordered)) result = _Subplan( entries=entries, @@ -336,10 +333,49 @@ def _expand( return result +def _entry_for( + component: ComponentRef, + declarations: Iterable[ComponentOverride], + overrides: Iterable[ComponentOverride] = (), +) -> _Entry: + """The only way to build an ``_Entry``; applies I3 to both histories. + + This is the single rule for what counts as one declaration: the component + and the bundle that declared it. The route to it is display-only, so only + the shortest chain is kept, with ties broken on the chain itself so the + result does not depend on merge order. Routes to the same declaration + collapse (otherwise a shared graph grows exponentially), while distinct + declaring bundles stay separate records. It serves both the declarations + behind an entry and the override history discarded by a parent. + """ + return _Entry( + component, + _merge_declarations(declarations), + _merge_declarations(overrides), + ) + + +def _merge_declarations( + *groups: Iterable[ComponentOverride], +) -> tuple[ComponentOverride, ...]: + """Merge declaration records to one per component and declaring bundle.""" + best: dict[tuple[ComponentRef, tuple[str, ...]], ComponentOverride] = {} + for group in groups: + for declaration in group: + key = (declaration.component, declaration.origin[-1:]) + current = best.get(key) + if current is None or (len(declaration.origin), declaration.origin) < ( + len(current.origin), + current.origin, + ): + best[key] = declaration + return tuple(best.values()) + + def _merge_candidate( entry: _Entry, candidates: dict[tuple[str, str], list[_Entry]], - ordered: list[tuple[str, str]], + reference_keys: list[tuple[str, str]], own_keys: set[tuple[str, str]], conflicts: list[str], declaring_chain: tuple[str, ...], @@ -348,11 +384,11 @@ def _merge_candidate( existing = candidates.get(key) if existing is None: candidates[key] = [entry] - ordered.append(key) + reference_keys.append(key) return differences = [ - _component_differences(previous.component, entry.component) + component_differences(previous.component, entry.component) for previous in existing ] if any(differences) and key not in own_keys: @@ -371,10 +407,10 @@ def _merge_candidate( for index, fields in enumerate(differences): if not fields: previous = existing[index] - existing[index] = _Entry( + existing[index] = _entry_for( previous.component, - _merge_declarations(previous.declarations, entry.declarations), - _merge_declarations(previous.overrides, entry.overrides), + declarations=(*previous.declarations, *entry.declarations), + overrides=(*previous.overrides, *entry.overrides), ) return @@ -383,32 +419,6 @@ def _merge_candidate( return -def _merge_declarations( - *groups: tuple[ComponentOverride, ...] | list[ComponentOverride], -) -> tuple[ComponentOverride, ...]: - """Merge declaration records to one per component and declaring bundle. - - This is the single rule for what counts as one declaration: the component - and the bundle that declared it. The route to it is display-only, so only - the shortest chain is kept, with ties broken on the chain itself so the - result does not depend on merge order. Routes to the same declaration - collapse (otherwise a shared graph grows exponentially), while distinct - declaring bundles stay separate records. It serves both the declarations - behind an entry and the override history discarded by a parent. - """ - best: dict[tuple[ComponentRef, tuple[str, ...]], ComponentOverride] = {} - for group in groups: - for declaration in group: - key = (declaration.component, declaration.origin[-1:]) - current = best.get(key) - if current is None or (len(declaration.origin), declaration.origin) < ( - len(current.origin), - current.origin, - ): - best[key] = declaration - return tuple(best.values()) - - def _declare_own( component: ComponentRef, existing: list[_Entry] ) -> tuple[_Entry, _OverrideNotice | None]: @@ -417,17 +427,23 @@ def _declare_own( Every declaration of a differing referenced copy is discarded and recorded, one per declaring bundle. Agreeing copies are not overrides. """ - inherited = _merge_declarations(*(entry.overrides for entry in existing)) + inherited = tuple(ov for entry in existing for ov in entry.overrides) replaced = tuple( declaration for entry in existing - if _component_differences(entry.component, component) + if component_differences(entry.component, component) for declaration in entry.declarations ) - entry = _Entry( + agreeing = tuple( + declaration + for entry in existing + if not component_differences(entry.component, component) + for declaration in entry.declarations + ) + entry = _entry_for( component, - (ComponentOverride(component, ()),), - _merge_declarations(inherited, replaced), + declarations=(ComponentOverride(component, ()), *agreeing), + overrides=(*inherited, *replaced), ) notice = _OverrideNotice(component, (), replaced) if replaced else None return entry, notice @@ -489,17 +505,6 @@ def _shortest_path( paths[key] = path -def _component_differences(a: ComponentRef, b: ComponentRef) -> list[str]: - different: list[str] = [] - if a.version is None or b.version is None: - if a.version != b.version: - different.append("version") - elif not same_version(a.version, b.version): - different.append("version") - different.extend(component_setting_differences(a, b)) - return different - - def _source_mismatch( ref: BundleRef, loaded: LoadedBundle, chain: tuple[str, ...] ) -> SourceMismatchError: @@ -523,7 +528,7 @@ def _component_name(component: ComponentRef) -> str: def _format_conflict( a: _Entry, b: _Entry, declaring_chain: tuple[str, ...] ) -> str: - fields = _component_differences(a.component, b.component) + fields = component_differences(a.component, b.component) left = "; ".join(_field_text(a.component, field) for field in fields) right = "; ".join(_field_text(b.component, field) for field in fields) left_chain = format_chain(declaring_chain + a.origin) @@ -543,7 +548,7 @@ def _format_warnings( own_chain = format_chain((root.bundle.id,) + notice.declaring) fields_by_entry: list[str] = [] differences_by_entry = [ - _component_differences(replaced.component, notice.component) + component_differences(replaced.component, notice.component) for replaced in notice.replaced ] for replaced, fields in zip(notice.replaced, differences_by_entry): diff --git a/tests/specify_cli/bundles/test_conflict.py b/tests/specify_cli/bundles/test_conflict.py index 097783e6e3..801d52e784 100644 --- a/tests/specify_cli/bundles/test_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -4,7 +4,12 @@ import pytest from specify_cli.bundles._commands import _bundle_overlaps -from specify_cli.bundles.conflict import component_setting_differences, detect_conflicts +from specify_cli.bundles.conflict import ( + component_differences, + component_setting_differences, + detect_conflicts, + same_requirement, +) from specify_cli.bundles.primitives import DEFAULT_PRIORITY from specify_cli.bundles.manifest import BundleManifest, ComponentRef from specify_cli.bundles.records import InstalledBundleRecord, save_records @@ -252,3 +257,17 @@ def test_setting_differences_extension_priority_opt_out_ignores_priority(): omitted = ComponentRef(kind="extensions", id="e") other = ComponentRef(kind="extensions", id="e", priority=DEFAULT_PRIORITY + 1) assert component_setting_differences(omitted, other, extension_priority=False) == [] + + +def test_component_differences_and_same_requirement(): + a = ComponentRef(kind="extensions", id="e", version="v1.0.0") + b = ComponentRef(kind="extensions", id="e", version="1.0.0") + assert component_differences(a, b) == [] + assert same_requirement(a, b) is True + + c = ComponentRef(kind="extensions", id="e", version="2.0.0") + assert component_differences(a, c) == ["version"] + assert same_requirement(a, c) is False + + d = ComponentRef(kind="extensions", id="other", version="v1.0.0") + assert same_requirement(a, d) is False diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py index a5ed05f0ed..b337ea2c2f 100644 --- a/tests/specify_cli/bundles/test_expansion.py +++ b/tests/specify_cli/bundles/test_expansion.py @@ -6,12 +6,12 @@ import pytest from specify_cli.bundles import BundlerError +from specify_cli.bundles.conflict import component_differences import specify_cli.bundles.expansion as expansion_module from specify_cli.bundles.expansion import ( MAX_REFERENCED_BUNDLES, ExpandedManifest, LoadedBundle, - _component_differences, expand_manifest, format_chain, require_installable, @@ -138,7 +138,7 @@ def counted_expand(manifest, chain, state): assert set(calls) == {"root", "left", "right", "shared", "leaf"} -def test_shorter_route_updates_override_and_preserves_cached_subplan(): +def test_shorter_route_updates_override_and_preserves_cached_subplan(monkeypatch): leaf = manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]) long = manifest("long", bundles=[{"id": "leaf", "version": "1.0.0"}]) root = manifest( @@ -150,23 +150,19 @@ def test_shorter_route_updates_override_and_preserves_cached_subplan(): extensions=[{"id": "e", "version": "2.0.0"}], ) loader = FakeBundleLoader(loaded(leaf, long)) - state = expansion_module._ExpansionState(loader) - expansion_module._expand(long, ("root", "long"), state) - cached_subplans = dict(state.subplans) - root_plan = expansion_module._expand(root, ("root",), state) - assert all(state.subplans[key] == plan for key, plan in cached_subplans.items()) - expanded = ExpandedManifest( - root, - [entry.component for entry in root_plan.entries], - { - (entry.component.kind, entry.component.id): expansion_module.ComponentOrigin( - entry.origin, entry.overrides - ) - for entry in root_plan.entries - }, - state.references, - expansion_module._format_warnings(root, root_plan.notices), - ) + cached_subplans: dict[str, expansion_module._Subplan] = {} + original_expand = expansion_module._expand + + def spy_expand(manifest, chain, state): + subplan = original_expand(manifest, chain, state) + if manifest.bundle.id not in cached_subplans and manifest.bundle.id in state.subplans: + cached_subplans[manifest.bundle.id] = state.subplans[manifest.bundle.id] + return subplan + + monkeypatch.setattr(expansion_module, "_expand", spy_expand) + expanded = expand_manifest(root, loader) + + assert "leaf" in cached_subplans assert expanded.origins[("extensions", "e")].origin == () assert next(ref for ref in expanded.references if ref.id == "leaf").via == () assert len(expanded.warnings) == 1 @@ -693,10 +689,10 @@ def test_require_installable_and_formatting(): def test_component_difference_uses_strict_pinned_rules(): - assert _component_differences( + assert component_differences( ComponentRef("steps", "lint", None), ComponentRef("steps", "lint", "1.0.0") ) == ["version"] - assert _component_differences( + assert component_differences( ComponentRef("extensions", "e", "v1.0.0"), ComponentRef("extensions", "e", "1.0.0") ) == [] @@ -882,3 +878,106 @@ def test_two_routes_to_one_declaring_bundle_stay_one_record(): overrides = expanded.origins[("extensions", "e")].overrides assert [item.origin for item in overrides] == [("left", "shared")] + + +def test_intermediate_agreeing_declaration_is_recorded_when_ancestor_overrides(): + leaf = manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]) + mid = manifest( + "mid", + bundles=[{"id": "leaf", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "1.0.0"}], + ) + root = manifest( + "root", + bundles=[{"id": "mid", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + loader = FakeBundleLoader(loaded(leaf, mid)) + expanded = expand_manifest(root, loader) + + origin = expanded.origins[("extensions", "e")] + assert origin.origin == () + assert len(origin.overrides) == 2 + override_origins = {override.origin for override in origin.overrides} + assert override_origins == {("mid",), ("mid", "leaf")} + assert len(expanded.warnings) == 1 + assert "root → mid" in expanded.warnings[0] + assert "root → mid → leaf" in expanded.warnings[0] + + +def test_agreeing_sibling_is_not_recorded_as_override(): + agree = manifest("agree", extensions=[{"id": "e", "version": "2.0.0"}]) + differ = manifest("differ", extensions=[{"id": "e", "version": "1.0.0"}]) + root = manifest( + "root", + bundles=[ + {"id": "agree", "version": "1.0.0"}, + {"id": "differ", "version": "1.0.0"}, + ], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + loader = FakeBundleLoader(loaded(agree, differ)) + expanded = expand_manifest(root, loader) + + origin = expanded.origins[("extensions", "e")] + assert origin.origin == () + assert len(origin.overrides) == 1 + assert origin.overrides[0].origin == ("differ",) + assert len(expanded.warnings) == 1 + assert "root → differ" in expanded.warnings[0] + assert "agree" not in expanded.warnings[0] + + +def test_agreeing_declarations_do_not_change_origin_or_order(): + leaf = manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]) + mid = manifest( + "mid", + bundles=[{"id": "leaf", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "1.0.0"}], + ) + root = manifest( + "root", + bundles=[{"id": "mid", "version": "1.0.0"}], + extensions=[{"id": "e", "version": "1.0.0"}], + ) + loader = FakeBundleLoader(loaded(leaf, mid)) + expanded = expand_manifest(root, loader) + + origin = expanded.origins[("extensions", "e")] + assert origin.origin == () + assert origin.overrides == () + assert [c.id for c in expanded.components] == ["e"] + assert expanded.warnings == [] + + +def _layered_agreeing_intermediate_graph(layers: int): + def refs(*ids): + return [{"id": item, "version": "1.0.0"} for item in ids] + + bundles = [ + manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]), + manifest( + "base", + bundles=refs("leaf"), + extensions=[{"id": "e", "version": "1.0.0"}], + ), + ] + previous: tuple[str, ...] = ("base",) + for layer in range(layers): + current = (f"l{layer}a", f"l{layer}b") + bundles.extend(manifest(name, bundles=refs(*previous)) for name in current) + previous = current + return bundles, previous + + +@pytest.mark.parametrize("layers", [1, 4, 7]) +def test_layered_agreeing_intermediate_keeps_override_records_bounded(layers): + bundles, top = _layered_agreeing_intermediate_graph(layers) + root = manifest( + "root", + bundles=[{"id": name, "version": "1.0.0"} for name in top], + extensions=[{"id": "e", "version": "2.0.0"}], + ) + expanded = expand_manifest(root, FakeBundleLoader(loaded(*bundles))) + overrides = expanded.origins[("extensions", "e")].overrides + assert len(overrides) == 2 From 7ac2f322f5f94dc279534d0cce7f5827cd6bc40e Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 08:20:15 +0200 Subject: [PATCH 19/27] fix(bundles): accept equivalent component spellings when reinstalling The reinstall guard compared stored components to the new plan with exact equality, so reinstalling the same parent version after a harmless reference reorder (extension priority unset vs 10, v1.0.0 vs 1.0.0) demanded --refresh. It now uses same_requirement, the rule expansion applies; a real change such as extension priority 5 -> 10 or a different version still requires --refresh. Assisted-by: Antigravity CLI (model: Gemini 3.8 Flash, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/installer.py | 7 +- tests/specify_cli/bundles/test_installer.py | 89 +++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index ff58d7b47b..0f384118ba 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -16,7 +16,7 @@ from typing import Protocol from . import BundlerError -from .conflict import component_setting_differences, detect_conflicts +from .conflict import component_setting_differences, detect_conflicts, same_requirement from .manifest import BundleManifest, BundleRef, ComponentRef from .primitives import BundledPatchWarning from .records import ( @@ -124,7 +124,10 @@ def install_bundle( and not refresh and ( existing.version != plan.version - or not set(existing.contributed_components).issubset(plan.components) + or not all( + any(same_requirement(component, planned) for planned in plan.components) + for component in existing.contributed_components + ) ) ): raise BundlerError( diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index c1e13ae977..244e387511 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -1339,3 +1339,92 @@ def test_install_result_changed_reports_uninstalled(): assert result.changed is False # empty == no change result.uninstalled.append(ComponentRef(kind="presets", id="p1")) assert result.changed is True + + +def test_reinstall_after_reference_reorder_does_not_require_refresh(tmp_path: Path): + make_project(tmp_path) + data_a = valid_manifest_dict() + data_a["bundle"]["id"] = "bundle-a" + data_a["bundle"]["version"] = "1.0.0" + data_a["provides"] = {"extensions": [{"id": "ext-a", "version": "1.0.0", "priority": 10}]} + bundle_a = BundleManifest.from_dict(data_a) + + data_b = valid_manifest_dict() + data_b["bundle"]["id"] = "bundle-b" + data_b["bundle"]["version"] = "1.0.0" + data_b["provides"] = {"extensions": [{"id": "ext-a", "version": "1.0.0"}]} + bundle_b = BundleManifest.from_dict(data_b) + + loader = FakeBundleLoader({ + ("bundle-a", "1.0.0"): LoadedBundle(bundle_a, "default", True), + ("bundle-b", "1.0.0"): LoadedBundle(bundle_b, "default", True), + }) + installer = FakeInstaller() + + # First install: references [bundle-a, bundle-b] + root1 = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": "bundle-a", "version": "1.0.0"}, {"id": "bundle-b", "version": "1.0.0"}], + bundle_id="root", + ) + ) + expanded1 = expand_manifest(root1, loader) + plan1 = resolve_install_plan(expanded1, speckit_version="0.11.2", active_integration="copilot") + install_bundle(tmp_path, plan1, installer, manifest=root1) + + # Second install: reorder references to [bundle-b, bundle-a] + root2 = BundleManifest.from_dict( + reference_manifest_dict( + [{"id": "bundle-b", "version": "1.0.0"}, {"id": "bundle-a", "version": "1.0.0"}], + bundle_id="root", + ) + ) + expanded2 = expand_manifest(root2, loader) + plan2 = resolve_install_plan(expanded2, speckit_version="0.11.2", active_integration="copilot") + + # Must succeed without --refresh + install_bundle(tmp_path, plan2, installer, manifest=root2) + assert installer.refresh_calls == [] + + +def test_reinstall_with_changed_extension_priority_requires_refresh(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + data1 = valid_manifest_dict() + data1["provides"] = {"extensions": [{"id": "ext-a", "version": "1.0.0", "priority": 5}]} + root1 = BundleManifest.from_dict(data1) + plan1 = _plan(root1) + install_bundle(tmp_path, plan1, installer, manifest=root1) + + data2 = valid_manifest_dict() + data2["provides"] = {"extensions": [{"id": "ext-a", "version": "1.0.0", "priority": 10}]} + root2 = BundleManifest.from_dict(data2) + plan2 = _plan(root2) + + with pytest.raises(BundlerError, match="--refresh"): + install_bundle(tmp_path, plan2, installer, manifest=root2) + + +def test_reinstall_with_version_spelling_change_is_accepted(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + data1 = valid_manifest_dict() + data1["provides"] = {"extensions": [{"id": "ext-a", "version": "v1.0.0"}]} + root1 = BundleManifest.from_dict(data1) + plan1 = _plan(root1) + install_bundle(tmp_path, plan1, installer, manifest=root1) + + # v1.0.0 -> 1.0.0 accepted without refresh + data2 = valid_manifest_dict() + data2["provides"] = {"extensions": [{"id": "ext-a", "version": "1.0.0"}]} + root2 = BundleManifest.from_dict(data2) + plan2 = _plan(root2) + install_bundle(tmp_path, plan2, installer, manifest=root2) + + # 1.0.0 -> 2.0.0 rejected + data3 = valid_manifest_dict() + data3["provides"] = {"extensions": [{"id": "ext-a", "version": "2.0.0"}]} + root3 = BundleManifest.from_dict(data3) + plan3 = _plan(root3) + with pytest.raises(BundlerError, match="--refresh"): + install_bundle(tmp_path, plan3, installer, manifest=root3) From 1c608de449f64c94e802b71bb30ddc8fc002a6fd Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 08:20:15 +0200 Subject: [PATCH 20/27] test(bundles): pin bundle info override formatting to component_differences _format_override compares JSON provenance through same_version and effective_setting on its own. A parametric test over kinds, fields and omitted or default priority asserts that it agrees with component_differences, so the two cannot drift. Assisted-by: Antigravity CLI (model: Gemini 3.8 Flash, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../specify_cli/bundles/test_command_info.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 854b29f2a9..1f21bb5315 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -1268,3 +1268,57 @@ def ref(value): {} if old is None else {field: old}, ) assert (f"{field}=" in text.split("overrides", 1)[1]) is differs + + +@pytest.mark.parametrize("kind", ["extensions", "presets", "steps", "workflows"]) +@pytest.mark.parametrize( + ("field", "old", "new"), + [ + ("version", "1.0.0", "v1.0.0"), + ("version", "1.0.0", "2.0.0"), + ("version", None, "1.0.0"), + ("source", None, "catalog"), + ("source", "a", "b"), + ("priority", None, 10), + ("priority", 10, None), + ("priority", 5, 10), + ("strategy", None, "append"), + ("strategy", "append", "replace"), + ], +) +def test_format_override_agrees_with_component_differences(kind, field, old, new): + from specify_cli.bundles.conflict import component_differences + from specify_cli.bundles.command_info import _format_override + + old_ref = ComponentRef(kind=kind, id="c", **({field: old} if old is not None else {})) + new_ref = ComponentRef(kind=kind, id="c", **({field: new} if new is not None else {})) + + diffs = component_differences(old_ref, new_ref) + + item = { + "kind": kind, + "id": "c", + "version": new_ref.version, + "source": new_ref.source, + "priority": new_ref.priority, + "strategy": new_ref.strategy, + } + override = { + "version": old_ref.version, + "source": old_ref.source, + "priority": old_ref.priority, + "strategy": old_ref.strategy, + "origin": ["child"], + } + text = _format_override(item, override) + + if not diffs: + assert "also declared by" in text or "same as final" in text + else: + assert text.startswith("overrides ") + assert "from child" in text + if field in diffs: + if field == "version": + assert (str(old) if old else "unpinned") in text + else: + assert f"{field}=" in text From f1f6edd5843be6e9c4dc33c3e7c276d3037a682f Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 08:20:15 +0200 Subject: [PATCH 21/27] fix(bundles): name the origin chain in inherited validate errors An unresolved reference inherited from a referenced bundle reported only the component, leaving the author to find which bundle declared it. Online validation now appends the chain, for example "(from parent -> child)". The root's own declarations, flat manifests and --offline keep today's text. Assisted-by: Antigravity CLI (model: Gemini 3.8 Flash, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/command_validate.py | 1 + src/specify_cli/bundles/validator.py | 7 +- .../bundles/test_command_validate.py | 64 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/bundles/command_validate.py b/src/specify_cli/bundles/command_validate.py index 5f473f57ba..227acb0860 100644 --- a/src/specify_cli/bundles/command_validate.py +++ b/src/specify_cli/bundles/command_validate.py @@ -101,6 +101,7 @@ def _validate(manifest, manifest_path: Path, *, offline: bool) -> ValidationRepo manifest, reference_checker=checker, components=expanded.components, + origins={key: origin.origin for key, origin in expanded.origins.items()}, ) report.errors.extend(discovery_only_messages(expanded)) report.warnings.extend(ref_warnings) diff --git a/src/specify_cli/bundles/validator.py b/src/specify_cli/bundles/validator.py index 9b0c999421..123bf2988e 100644 --- a/src/specify_cli/bundles/validator.py +++ b/src/specify_cli/bundles/validator.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Callable +from typing import Callable, Mapping from . import BundlerError from .versioning import parse_constraint @@ -36,6 +36,7 @@ def validate_manifest( manifest: BundleManifest, reference_checker: ReferenceChecker | None = None, components: list[ComponentRef] | None = None, + origins: Mapping[tuple[str, str], tuple[str, ...]] | None = None, ) -> ValidationReport: report = ValidationReport() @@ -55,8 +56,10 @@ def validate_manifest( for component in selected_components: problem = reference_checker(component) if problem: + origin = origins.get((component.kind, component.id)) if origins else None + chain_text = f" (from {' → '.join((manifest.bundle.id,) + origin)})" if origin else "" report.errors.append( - f"Unresolved reference {component.label()}: {problem}" + f"Unresolved reference {component.label()}{chain_text}: {problem}" ) return report diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index 3c8694a340..3674fb4388 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -481,3 +481,67 @@ def resolve(self, bundle_id, version=None): result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) assert result.exit_code == 0, result.output assert "overrides" in result.output + + +def test_validate_inherited_component_error_names_origin_chain(project: Path, monkeypatch): + parent = reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], + bundle_id="parent", + extensions=[{"id": "own-ext", "version": "1.0.0"}], + ) + child = valid_manifest_dict( + bundle={"id": "child", "name": "Child", "version": "1.0.0", "role": "developer", "description": "child", "author": "tests", "license": "MIT"}, + provides={"extensions": [{"id": "child-ext", "version": "1.0.0"}]}, + ) + path = project / "bundle.yml" + path.write_text(yaml.safe_dump(parent), encoding="utf-8") + selected = SimpleNamespace( + entry=SimpleNamespace(id="child", version="1.0.0", verified=True), + source=SimpleNamespace(id="default"), + install_allowed=True, + ) + + class Stack: + def resolve(self, bundle_id, version=None): + return selected + + monkeypatch.setattr("specify_cli.bundles._commands._build_stack", lambda *a, **k: Stack()) + monkeypatch.setattr("specify_cli.bundles.command_validate._download_manifest", lambda *a, **k: BundleManifest.from_dict(child)) + monkeypatch.setattr( + "specify_cli.bundles.references.make_reference_checker", + lambda *a, **k: lambda component: f"unresolved {component.id}", + ) + result = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result.exit_code == 1, result.output + output = " ".join(result.output.split()) + assert "Unresolved reference extension:child-ext@1.0.0 (from parent → child): unresolved child-ext" in output + assert "Unresolved reference extension:own-ext@1.0.0: unresolved own-ext" in output + assert "from parent" not in output.split("own-ext@1.0.0", 1)[1] + + +def test_validate_offline_and_flat_error_text_unchanged(project: Path, monkeypatch): + flat_data = valid_manifest_dict( + bundle={"id": "flat-bundle", "name": "Flat", "version": "1.0.0", "role": "developer", "description": "flat", "author": "tests", "license": "MIT"}, + provides={"extensions": [{"id": "ext-a", "version": "1.0.0"}]}, + ) + path = project / "bundle.yml" + path.write_text(yaml.safe_dump(flat_data), encoding="utf-8") + monkeypatch.setattr( + "specify_cli.bundles.references.make_reference_checker", + lambda *a, **k: lambda component: "flat problem", + ) + result_flat = runner.invoke(app, ["bundle", "validate", "--path", str(path)]) + assert result_flat.exit_code == 1 + assert "Unresolved reference extension:ext-a@1.0.0: flat problem" in " ".join(result_flat.output.split()) + assert "(from" not in result_flat.output + + ref_data = reference_manifest_dict( + [{"id": "child", "version": "1.0.0"}], + bundle_id="parent-bundle", + extensions=[{"id": "own-ext", "version": "1.0.0"}], + ) + path.write_text(yaml.safe_dump(ref_data), encoding="utf-8") + result_offline = runner.invoke(app, ["bundle", "validate", "--offline", "--path", str(path)]) + assert result_offline.exit_code == 1 + assert "Unresolved reference extension:own-ext@1.0.0: flat problem" in " ".join(result_offline.output.split()) + assert "(from" not in result_offline.output From c90900a7fec405b17086c51b118b112d16606fb5 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 08:20:15 +0200 Subject: [PATCH 22/27] docs(bundles): qualify validate warnings and restate override provenance Only component references that cannot be checked produce warnings; a bundle-reference fetch failure fails online validation, while --offline keeps the per-reference warning. bundle info override entries name every distinct bundle that declared the replaced value, not only the first route. Assisted-by: Antigravity CLI (model: Gemini 3.8 Flash, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 31dcbb2aae..90ad7901ca 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -55,7 +55,7 @@ specify bundle info --versions | `--json` | Emit machine-readable JSON | | `--versions` | List the current and historical catalog releases | -Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary, with each referenced bundle's own trust level (`verified` or `community`), and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, `via`, and the referenced bundle's own `verified` and `trust`; for bundles with references, component entries also include `source` when the manifest declares one (flat bundles omit it), `origin` (the chain of bundles that contributed the component) when it comes from a referenced bundle, and `overrides` whenever the entry replaced other declarations, including a parent's own declaration, which has no `origin`. Each `overrides` item carries the replaced `version` and `origin`, plus any `source`, `priority`, or `strategy`. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. +Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary, with each referenced bundle's own trust level (`verified` or `community`), and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, `via`, and the referenced bundle's own `verified` and `trust`; for bundles with references, component entries also include `source` when the manifest declares one (flat bundles omit it), `origin` (the chain of bundles that contributed the component) when it comes from a referenced bundle, and `overrides` whenever the entry replaced other declarations, including a parent's own declaration, which has no `origin`. Each `overrides` item carries the replaced `version` and `origin`, plus any `source`, `priority`, or `strategy`; an override entry names every distinct bundle that declared the replaced value (keeping the shortest route to each), not only the first route. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. Use `specify bundle info --versions` to inspect the catalog history without downloading a bundle manifest. With `--json`, it emits the bundle ID, available versions, current version, source, and install policy. @@ -291,7 +291,7 @@ specify bundle validate | `--path` | Bundle directory or `bundle.yml` (default: current directory) | | `--offline` | Verify references against bundled/installed components only | -Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. For a reference-bearing manifest, online validation expands the complete reference graph and checks every component in the expanded plan, including structural, cycle, version, source, conflict, and install-policy checks. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. In `--offline` mode, each declared bundle reference produces a warning that it was not expanded. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. Validation does not evaluate `requires.speckit_version` against the running CLI; `install` and `update` enforce that runtime compatibility gate. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins. +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. For a reference-bearing manifest, online validation expands the complete reference graph and checks every component in the expanded plan, including structural, cycle, version, source, conflict, and install-policy checks. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; component references that cannot be checked offline or because a catalog is unreachable produce warnings. Bundle-reference fetch failures (such as an unreachable bundle catalog or a failed child-manifest download) fail online validation, while `--offline` keeps the per-reference warning. In `--offline` mode, each declared bundle reference produces a warning that it was not expanded. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. Validation does not evaluate `requires.speckit_version` against the running CLI; `install` and `update` enforce that runtime compatibility gate. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins. ## Build a Bundle Artifact From 319f50763508c9e1881e8c8bc1bb64a869f9f465 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 08:20:56 +0200 Subject: [PATCH 23/27] refactor(bundles): build prefixed entries through _entry_for _prefix_entry still constructed _Entry directly, so _entry_for was not the only constructor its docstring claims. Route it through _entry_for. The merge is a no-op for prefixed records: prefixing keeps each declaring bundle id, and cycle rejection prevents the prefix from colliding with one. Assisted-by: GitHub Copilot CLI (model: Claude Opus 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/expansion.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py index e361169c3c..0ee90006ea 100644 --- a/src/specify_cli/bundles/expansion.py +++ b/src/specify_cli/bundles/expansion.py @@ -464,10 +464,10 @@ def _prefix_declarations( def _prefix_entry(entry: _Entry, prefix: tuple[str, ...]) -> _Entry: - return _Entry( + return _entry_for( entry.component, - _prefix_declarations(entry.declarations, prefix), - _prefix_declarations(entry.overrides, prefix), + declarations=_prefix_declarations(entry.declarations, prefix), + overrides=_prefix_declarations(entry.overrides, prefix), ) From 99a1e394e9cf60d0dab0de98e97b9f93feb698bd Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 08:20:56 +0200 Subject: [PATCH 24/27] test(bundles): assert cached subplan identity and multi-key ordering The cached-subplan test only checked that leaf had been cached, so re-expanding or replacing it on the shorter route would still pass. It now asserts leaf is expanded once and that the cached subplans for leaf and long are the same objects with unchanged contents. The agreeing-declarations test used one component, so its order assertion could not fail. It now covers first-seen reference keys, an own key that is also referenced, the first-spelling rule and the origin tie-break. Both tests fail under injected mutations: re-expanding on a cache hit, replacing the cache entry, keeping own keys in the reference section, choosing the longest origin, and keeping the last spelling. Assisted-by: GitHub Copilot CLI (model: Claude Opus 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/specify_cli/bundles/test_expansion.py | 88 +++++++++++++++++---- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py index b337ea2c2f..86a7af9123 100644 --- a/tests/specify_cli/bundles/test_expansion.py +++ b/tests/specify_cli/bundles/test_expansion.py @@ -150,19 +150,40 @@ def test_shorter_route_updates_override_and_preserves_cached_subplan(monkeypatch extensions=[{"id": "e", "version": "2.0.0"}], ) loader = FakeBundleLoader(loaded(leaf, long)) - cached_subplans: dict[str, expansion_module._Subplan] = {} + calls: list[str] = [] + first_subplans: dict[str, expansion_module._Subplan] = {} + snapshots: dict[str, tuple] = {} + states: list[expansion_module._ExpansionState] = [] original_expand = expansion_module._expand + def snapshot(subplan): + return ( + subplan.entries, + dict(subplan.reference_paths), + subplan.longest_path, + subplan.notices, + ) + def spy_expand(manifest, chain, state): subplan = original_expand(manifest, chain, state) - if manifest.bundle.id not in cached_subplans and manifest.bundle.id in state.subplans: - cached_subplans[manifest.bundle.id] = state.subplans[manifest.bundle.id] + bundle_id = manifest.bundle.id + calls.append(bundle_id) + states.append(state) + if bundle_id not in first_subplans: + first_subplans[bundle_id] = subplan + snapshots[bundle_id] = snapshot(subplan) return subplan monkeypatch.setattr(expansion_module, "_expand", spy_expand) expanded = expand_manifest(root, loader) - assert "leaf" in cached_subplans + # The shorter route root → leaf reuses the subplan cached via root → long + # → leaf; it neither re-expands, replaces, nor mutates it. + assert calls.count("leaf") == 1 + state = states[-1] + for bundle_id in ("leaf", "long"): + assert state.subplans[bundle_id] is first_subplans[bundle_id] + assert snapshot(state.subplans[bundle_id]) == snapshots[bundle_id] assert expanded.origins[("extensions", "e")].origin == () assert next(ref for ref in expanded.references if ref.id == "leaf").via == () assert len(expanded.warnings) == 1 @@ -929,24 +950,61 @@ def test_agreeing_sibling_is_not_recorded_as_override(): def test_agreeing_declarations_do_not_change_origin_or_order(): - leaf = manifest("leaf", extensions=[{"id": "e", "version": "1.0.0"}]) + leaf = manifest( + "leaf", + extensions=[ + {"id": "inherited", "version": "1.0.0"}, + {"id": "shared", "version": "v1.0.0"}, + ], + ) mid = manifest( "mid", bundles=[{"id": "leaf", "version": "1.0.0"}], - extensions=[{"id": "e", "version": "1.0.0"}], + extensions=[ + {"id": "mid-ext", "version": "1.0.0"}, + {"id": "shared", "version": "1.0.0"}, + ], + ) + other = manifest( + "other", + extensions=[ + {"id": "other-ext", "version": "1.0.0"}, + {"id": "shared", "version": "v1.0.0"}, + ], ) root = manifest( "root", - bundles=[{"id": "mid", "version": "1.0.0"}], - extensions=[{"id": "e", "version": "1.0.0"}], + bundles=[ + {"id": "mid", "version": "1.0.0"}, + {"id": "other", "version": "1.0.0"}, + ], + extensions=[ + {"id": "root-ext", "version": "1.0.0"}, + {"id": "inherited", "version": "1.0.0"}, + ], ) - loader = FakeBundleLoader(loaded(leaf, mid)) - expanded = expand_manifest(root, loader) - - origin = expanded.origins[("extensions", "e")] - assert origin.origin == () - assert origin.overrides == () - assert [c.id for c in expanded.components] == ["e"] + expanded = expand_manifest(root, FakeBundleLoader(loaded(leaf, mid, other))) + + # Reference-derived keys in first-seen order, then root's own keys in + # manifest order; "inherited" is both, so it moves to the own section. + assert [c.id for c in expanded.components] == [ + "mid-ext", + "shared", + "other-ext", + "root-ext", + "inherited", + ] + # mid's own spelling wins over leaf's agreeing copy and is the first + # spelling root sees; other's agreeing copy does not replace it. + shared = next(c for c in expanded.components if c.id == "shared") + assert shared.version == "1.0.0" + # The retained ("mid", "leaf") and ("other",) declarations do not displace + # the shortest, lexicographically first route. + assert expanded.origins[("extensions", "shared")].origin == ("mid",) + assert expanded.origins[("extensions", "inherited")].origin == () + assert expanded.origins[("extensions", "mid-ext")].origin == ("mid",) + assert expanded.origins[("extensions", "other-ext")].origin == ("other",) + assert all(origin.overrides == () for origin in expanded.origins.values()) assert expanded.warnings == [] From 95c4a9ea7863e341247172dbdab2ee9bc86ca23b Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Sat, 24 Oct 2026 09:05:26 +0200 Subject: [PATCH 25/27] fix(bundles): tell authors how to resolve conflicting bundle pins The conflicting-pin error named both versions and both reference chains but never stated the remedy, unlike the sibling component-conflict message which ends with "declare ... to choose". Append the equivalent guidance: choose referencing releases that pin the same version on every route. Assisted-by: pi (model: deepseek-v4.1-flash, supervised) --- docs/reference/bundles.md | 3 ++- src/specify_cli/bundles/expansion.py | 3 ++- tests/specify_cli/bundles/test_expansion.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 90ad7901ca..d0e79db865 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -142,7 +142,8 @@ precedence; a mismatch is an error and does not fall through to another catalog. A referenced bundle from a discovery-only source can be inspected by `info`, but `install`, `update`, and online `validate` reject it as non-installable. Every bundle ID may have only one version in an expansion; -cycles and a second version for the same ID are errors. +cycles and a second version for the same ID are errors. Resolve a second +version by choosing releases of the referencing bundles that agree on it. The expansion is one flat component plan. Referenced bundles are walked recursively in declaration order (depth-first), followed by the declaring diff --git a/src/specify_cli/bundles/expansion.py b/src/specify_cli/bundles/expansion.py index 0ee90006ea..52c5e43584 100644 --- a/src/specify_cli/bundles/expansion.py +++ b/src/specify_cli/bundles/expansion.py @@ -232,7 +232,8 @@ def _expand( raise BundlerError( f"Bundle '{ref.id}' is referenced at versions {old_version[0]} " f"({format_chain(old_version[1])}) and {ref.version or ''} " - f"({format_chain(chain)})." + f"({format_chain(chain)}); choose referencing releases that pin " + "the same version on every route." ) if old_version is None: state.versions[ref.id] = (ref.version or "", chain) diff --git a/tests/specify_cli/bundles/test_expansion.py b/tests/specify_cli/bundles/test_expansion.py index 86a7af9123..73188615e6 100644 --- a/tests/specify_cli/bundles/test_expansion.py +++ b/tests/specify_cli/bundles/test_expansion.py @@ -466,6 +466,7 @@ def test_version_conflict_names_both_chains_and_matching_spellings_are_accepted( message = str(caught.value) assert "1.0.0 (root → first)" in message assert "2.0.0 (root → second)" in message + assert "choose referencing releases that pin the same version" in message accepted_first = manifest("accepted-first", bundles=[{"id": "leaf", "version": "v1.0.0"}]) accepted_second = manifest("accepted-second", bundles=[{"id": "leaf", "version": "1.0.0"}]) From 2480da69c8d37b8c627eb7ce9b16801ac39dad3f Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 09:41:37 +0200 Subject: [PATCH 26/27] perf(bundles): index planned components in the reinstall guard The reinstall guard compared every owned component against every planned one, because the semantic check that replaced set membership scans the plan per component. An unchanged reinstall of a 2000-component plan therefore made 2,001,000 same_requirement calls (~0.2 s). Indexing the plan by (kind, id) reduces that to one comparison per owned component. Behaviour is unchanged: same_requirement still normalizes version spellings and omitted extension priorities, a missing key still reports a changed/removed component, and per-key candidate lists keep `any` semantics if a plan repeats a key (structural_errors already rejects duplicate kind:id in a manifest). The new regression test fails against the previous guard (1275 comparisons for 50 components) and passes now. tests/specify_cli/bundles: 1228 passed. Assisted-by: pi (model: deepseek-v4.1-flash, supervised) --- src/specify_cli/bundles/installer.py | 15 +++++++++- tests/specify_cli/bundles/test_installer.py | 33 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 0f384118ba..eaf3eae4a4 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -119,13 +119,26 @@ def install_bundle( result = InstallResult(bundle_id=plan.bundle_id) existing = find_record(records, plan.bundle_id) + # Index the plan by (kind, id) so an unchanged reinstall compares each owned + # component against its candidate instead of scanning the whole plan. Plan + # components are unique per key (structural_errors rejects duplicates), and + # same_requirement still applies the expansion normalization; the per-key + # list keeps `any` semantics if a plan ever repeats a key. + planned_by_key: dict[tuple[str, str], list[ComponentRef]] = {} + for planned in plan.components: + planned_by_key.setdefault((planned.kind, planned.id), []).append(planned) if ( existing is not None and not refresh and ( existing.version != plan.version or not all( - any(same_requirement(component, planned) for planned in plan.components) + any( + same_requirement(component, candidate) + for candidate in planned_by_key.get( + (component.kind, component.id), () + ) + ) for component in existing.contributed_components ) ) diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index 244e387511..cb22694609 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -1428,3 +1428,36 @@ def test_reinstall_with_version_spelling_change_is_accepted(tmp_path: Path): plan3 = _plan(root3) with pytest.raises(BundlerError, match="--refresh"): install_bundle(tmp_path, plan3, installer, manifest=root3) + + +def test_reinstall_guard_compares_each_component_once(tmp_path: Path, monkeypatch): + """An unchanged reinstall must not rescan the plan for every owned component. + + The guard asks whether every owned component is still required with an + equivalent spelling. Same-version bundles therefore reach it on every plain + reinstall, so it indexes the plan by ``(kind, id)``; the nested scan it + replaced compared every owned component against every planned one. + """ + from specify_cli.bundles import installer as installer_module + + make_project(tmp_path) + installer = FakeInstaller() + extension_ids = [f"ext-{index}" for index in range(50)] + manifest = _bundle("demo", extension_ids) + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + installer.install_calls.clear() + + real = installer_module.same_requirement + comparisons: list[tuple[ComponentRef, ComponentRef]] = [] + + def counting(component, planned): + comparisons.append((component, planned)) + return real(component, planned) + + monkeypatch.setattr(installer_module, "same_requirement", counting) + result = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + # One candidate per owned component (the bound leaves room for a second + # comparison pass); a full scan would be 1275 comparisons for 50 components. + assert result.changed is False + assert len(comparisons) <= 2 * len(extension_ids) From 647b73340171a38f61f115b8db74b3a69e64c149 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 24 Oct 2026 15:37:54 +0200 Subject: [PATCH 27/27] fix(bundles): show one v prefix for v-pinned reference labels A reference pin such as v1.0.1 is structurally valid, so `bundle info` rendered it as "vv1.0.1": the label adds its own "v" to the recorded pin text. Text labels now strip an optional leading v/V. The stripping lives in a new public `versioning.strip_version_prefix`, which removes the prefix only. Reusing `_normalize_semver` would also rewrite prerelease spellings (1.2.3-rc1 -> 1.2.3rc1) and make the label disagree with the recorded version. The JSON view keeps the pin verbatim, and other display sites keep their current behaviour; consolidating them on the new helper is tracked as follow-up work in the PR description. Regression evidence: test_info_strips_redundant_v_prefix_from_reference_label fails on the previous formatter ("- child-bundle vv1.0.1 (local; verified)") and passes now. tests/specify_cli/bundles: 1237 passed. Assisted-by: pi (model: deepseek-v4.1-flash, supervised) --- src/specify_cli/bundles/command_info.py | 8 ++- src/specify_cli/bundles/versioning.py | 11 ++++ .../specify_cli/bundles/test_command_info.py | 52 +++++++++++++++++++ tests/specify_cli/bundles/test_versioning.py | 22 +++++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py index 0848b9323b..f2ae7b557e 100644 --- a/src/specify_cli/bundles/command_info.py +++ b/src/specify_cli/bundles/command_info.py @@ -23,7 +23,7 @@ from .conflict import effective_setting from .project import find_project_root from .sources import _download_manifest -from .versioning import same_version +from .versioning import same_version, strip_version_prefix @bundle_app.command("info") @@ -249,7 +249,11 @@ def _reference_view(expanded) -> list[dict]: def _format_reference(reference) -> str: - label = f"{reference.id} v{reference.version} ({reference.source_id}" + # The label supplies its own "v", and an accepted pin may already carry one + # (``v1.0.1``); strip it here so the text shows one prefix. The JSON view + # keeps the recorded spelling. + label = f"{reference.id} v{strip_version_prefix(reference.version)}" + label += f" ({reference.source_id}" label += f"; {_trust_level(reference.verified)}" if reference.via: label += f"; via {' → '.join(reference.via)}" diff --git a/src/specify_cli/bundles/versioning.py b/src/specify_cli/bundles/versioning.py index 19ea4e72f3..b22688ae11 100644 --- a/src/specify_cli/bundles/versioning.py +++ b/src/specify_cli/bundles/versioning.py @@ -18,6 +18,17 @@ ) +def strip_version_prefix(value: str) -> str: + """Return *value* without an optional leading ``v``/``V``. + + Display formatter: only the prefix is removed, so prerelease spellings stay + as the author wrote them. ``_normalize_semver`` also rewrites those, which + would make a label disagree with the recorded version. + """ + text = str(value) + return text[1:] if text[:1] in ("v", "V") else text + + def _normalize_semver(value: str) -> str: """Normalize common SemVer prerelease spellings into PEP 440 text.""" text = str(value) diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 1f21bb5315..3c2423551d 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -1322,3 +1322,55 @@ def test_format_override_agrees_with_component_differences(kind, field, old, new assert (str(old) if old else "unpinned") in text else: assert f"{field}=" in text + + +def test_info_strips_redundant_v_prefix_from_reference_label( + project: Path, monkeypatch +): + """An accepted ``v1.0.1`` pin shows one prefix in text, raw in JSON.""" + parent_dir = project / "parent-bundle" + child_dir = project / "child-bundle" + parent_dir.mkdir() + child_dir.mkdir() + parent_path = parent_dir / "bundle.yml" + child_path = child_dir / "bundle.yml" + parent_data = reference_manifest_dict( + [{"id": "child-bundle", "version": "v1.0.1"}] + ) + child_data = reference_manifest_dict([]) + child_data["bundle"]["id"] = "child-bundle" + child_data["bundle"]["version"] = "1.0.1" + parent_path.write_text(yaml.safe_dump(parent_data), encoding="utf-8") + child_path.write_text(yaml.safe_dump(child_data), encoding="utf-8") + catalog = project / "prefix-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict("demo-bundle"), + "child-bundle": catalog_entry_dict( + "child-bundle", + version="1.0.1", + download_url="https://example.com/child.zip", + ), + }, + ) + added = runner.invoke(app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]) + assert added.exit_code == 0, added.output + + def fake_download(resolved, *, offline): + path = {"demo-bundle": parent_path, "child-bundle": child_path}[resolved.entry.id] + return BundleManifest.from_file(path) + + monkeypatch.setattr("specify_cli.bundles.command_info._download_manifest", fake_download) + + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert text.exit_code == 0, text.output + output = " ".join(strip_ansi(text.output).split()) + assert "- child-bundle v1.0.1 (local; verified)" in output + assert "vv1.0.1" not in output + + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) + assert result.exit_code == 0, result.output + (reference,) = json.loads(result.output)["references"] + # JSON keeps the recorded pin, including the prefix. + assert reference["version"] == "v1.0.1" diff --git a/tests/specify_cli/bundles/test_versioning.py b/tests/specify_cli/bundles/test_versioning.py index a5748cdd5a..4e4d185d06 100644 --- a/tests/specify_cli/bundles/test_versioning.py +++ b/tests/specify_cli/bundles/test_versioning.py @@ -4,7 +4,12 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundles.versioning import is_newer_patch, is_semver, satisfies +from specify_cli.bundles.versioning import ( + is_newer_patch, + is_semver, + satisfies, + strip_version_prefix, +) @pytest.mark.parametrize("actual,pinned,expected", [ @@ -144,3 +149,18 @@ def test_valid_constraints_are_unaffected(constraint, expected): from specify_cli.bundles.versioning import parse_constraint assert str(parse_constraint(constraint)) == expected + + +@pytest.mark.parametrize("value,expected", [ + ("1.0.0", "1.0.0"), + ("v1.0.0", "1.0.0"), + ("V1.0.0", "1.0.0"), + ("v1.2.3-rc1", "1.2.3-rc1"), + ("1.2.3-rc1", "1.2.3-rc1"), + ("v0.8.0.dev0", "0.8.0.dev0"), + ("vv1.0.0", "v1.0.0"), + ("", ""), +]) +def test_strip_version_prefix_removes_only_one_prefix(value: str, expected: str): + """Display keeps prerelease spelling; only the leading v/V is dropped.""" + assert strip_version_prefix(value) == expected