mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat: add specify bundle command (#3070)
* docs: dogfood Spec Kit — bundler SDD artifacts + constitution Scaffold Spec Kit (--integration copilot) and run the full SDD workflow against the `specify bundle` subcommand feature: - spec.md (4 user stories, 31 FRs, 8 success criteria) + clarifications - plan.md, research.md, data-model.md, contracts/, quickstart.md - tasks.md (43 dependency-ordered tasks, organized by user story) - Spec Kit Constitution v1.0.0 (code quality, testing, UX, performance, dependency/security principles) derived from deep codebase analysis - plan Constitution Check + tasks grounded against the ratified principles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): add `specify bundle` subcommand for role-based setups Implements the Spec Kit Bundler as a `specify bundle ...` subcommand group that calls existing primitive machinery in-process with zero new dependencies, per the v1.0.0 constitution (Principles I-V). Adds the `specify_cli.bundler` package (models, services, lib helpers) and the `commands/bundle` Typer group wiring search, info, list, install, update, remove, validate, build, init, and catalog list/add/remove (with --json and --offline). Includes manifest/catalog schemas, version + integration-clash gating, discovery-only refusal, idempotent install with atomic rollback, non-collateral removal, and offline-first catalog resolution. Ships an 82-test suite (contract/unit/integration), four sample role bundles (product-manager, business-analyst, security-researcher, developer), README "Bundles" docs, and an AGENTS.md pitfall on the test-venv gotcha. Marks tasks T001-T043 complete and records follow-ups T044 (live in-process primitive dispatch) and T045 (install from a local artifact path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(contributing): document running the full test suite via project .venv Add a "Running the full test suite" subsection under Automated checks covering `uv pip install -e ".[test]"` + `.venv/bin/python -m pytest`, with the shared/global editable-install contamination caveat that mirrors the AGENTS.md pitfall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): wire real in-process primitive install + local-artifact install Closes the two follow-ups left after the initial bundler landing. T044 — DefaultPrimitiveInstaller now performs real installs through existing machinery instead of raising "use the primitive command" errors: - presets/extensions install via their reusable managers (install_from_directory / install_from_zip); bundled assets install fully offline, catalog assets are fetched only when the network is allowed. - workflows/steps delegate to the existing `workflow add` / `workflow step add` command callables in-process (project root as cwd), avoiding any duplicated download/validation logic (Principle I). - `--offline` is threaded through DefaultPrimitiveInstaller(allow_network=…) so network-only kinds refuse with an actionable message rather than silently reaching out. T045 — `specify bundle install` now accepts a local path (a built .zip artifact, a bundle directory, or a bundle.yml) and installs directly without consulting the catalog stack; bundle-ids still resolve via the stack. Adds 13 tests (routing, offline gating, local-source resolution, and an end-to-end offline build → install → list → remove of the bundled agent-context extension). Bundler suite: 95 passing; ruff clean. Marks T044 and T045 complete in tasks.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(bundler): append Phase 8 convergence tasks from converge assessment Ran the converge command: assessed the codebase against spec.md, plan.md, tasks.md, and the v1.0.0 constitution. Appended 7 traceable gap-closure tasks (T046–T052) as a new "Phase 8: Convergence" section. Append-only — no existing tasks were modified and no application code was changed. Findings: 1 CRITICAL (Constitution III — bundle group undocumented under docs/reference/), 3 HIGH (FR-005/SC-007 validate references; FR-009/SC-002 info expansion; FR-012 install-time init), 3 MEDIUM (FR-013 integration precedence; FR-020 surface overlaps; FR-028 update refresh). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement Phase 8 convergence tasks (T046–T052) Close the gaps the converge command found between the bundler spec/plan/ constitution and the code: - T046: add docs/reference/bundles.md documenting the full `specify bundle` command group; link it from docs/reference/overview.md (Constitution III). - T047: wire a reference checker into `bundle validate` (services/references.py); online runs fail and name unresolved component references, offline runs warn. - T048: expand `bundle info` to enumerate the full component set (versions, preset priority/strategy) plus the bundle integration — info == install. - T049/T050: `bundle install`/`bundle init` now scaffold an uninitialized project via the existing `specify init` machinery, choosing the integration by precedence (override → bundle-declared → Copilot + OS default script type). - T051: surface foreseeable component overlaps during info and install. - T052: `bundle update` refreshes already-installed components via a new refresh path in install_bundle, preserving primitive-level overrides. Adds unit/contract/integration coverage (107 tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * converge: append Phase 9 (T053) — surface bundle trust indicator Re-run of converge after Phase 8. The seven Phase 8 tasks are verified closed. One residual partial gap remains: the `verified`/trust indicator (FR-010, FR-027) is exposed only in `bundle info --json`, absent from `bundle search` (the primary discovery surface) and `bundle info` text. Appended as a single new task for implement to complete. Append-only; no code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement T053 — surface bundle trust indicator in discovery `bundle search` (text + JSON) and `bundle info` (text + JSON) now expose each catalog entry's verification/trust level (verified vs community), so users can judge a bundle's trust before installing, per FR-010 / FR-027. Previously `verified` was only present in `bundle info --json`. Adds contract coverage; 108 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: dogfood Spec Kit — bundler SDD artifacts + constitution Scaffold Spec Kit (--integration copilot) and run the full SDD workflow against the `specify bundle` subcommand feature: - spec.md (4 user stories, 31 FRs, 8 success criteria) + clarifications - plan.md, research.md, data-model.md, contracts/, quickstart.md - tasks.md (43 dependency-ordered tasks, organized by user story) - Spec Kit Constitution v1.0.0 (code quality, testing, UX, performance, dependency/security principles) derived from deep codebase analysis - plan Constitution Check + tasks grounded against the ratified principles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): add `specify bundle` subcommand for role-based setups Implements the Spec Kit Bundler as a `specify bundle ...` subcommand group that calls existing primitive machinery in-process with zero new dependencies, per the v1.0.0 constitution (Principles I-V). Adds the `specify_cli.bundler` package (models, services, lib helpers) and the `commands/bundle` Typer group wiring search, info, list, install, update, remove, validate, build, init, and catalog list/add/remove (with --json and --offline). Includes manifest/catalog schemas, version + integration-clash gating, discovery-only refusal, idempotent install with atomic rollback, non-collateral removal, and offline-first catalog resolution. Ships an 82-test suite (contract/unit/integration), four sample role bundles (product-manager, business-analyst, security-researcher, developer), README "Bundles" docs, and an AGENTS.md pitfall on the test-venv gotcha. Marks tasks T001-T043 complete and records follow-ups T044 (live in-process primitive dispatch) and T045 (install from a local artifact path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(contributing): document running the full test suite via project .venv Add a "Running the full test suite" subsection under Automated checks covering `uv pip install -e ".[test]"` + `.venv/bin/python -m pytest`, with the shared/global editable-install contamination caveat that mirrors the AGENTS.md pitfall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): wire real in-process primitive install + local-artifact install Closes the two follow-ups left after the initial bundler landing. T044 — DefaultPrimitiveInstaller now performs real installs through existing machinery instead of raising "use the primitive command" errors: - presets/extensions install via their reusable managers (install_from_directory / install_from_zip); bundled assets install fully offline, catalog assets are fetched only when the network is allowed. - workflows/steps delegate to the existing `workflow add` / `workflow step add` command callables in-process (project root as cwd), avoiding any duplicated download/validation logic (Principle I). - `--offline` is threaded through DefaultPrimitiveInstaller(allow_network=…) so network-only kinds refuse with an actionable message rather than silently reaching out. T045 — `specify bundle install` now accepts a local path (a built .zip artifact, a bundle directory, or a bundle.yml) and installs directly without consulting the catalog stack; bundle-ids still resolve via the stack. Adds 13 tests (routing, offline gating, local-source resolution, and an end-to-end offline build → install → list → remove of the bundled agent-context extension). Bundler suite: 95 passing; ruff clean. Marks T044 and T045 complete in tasks.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(bundler): append Phase 8 convergence tasks from converge assessment Ran the converge command: assessed the codebase against spec.md, plan.md, tasks.md, and the v1.0.0 constitution. Appended 7 traceable gap-closure tasks (T046–T052) as a new "Phase 8: Convergence" section. Append-only — no existing tasks were modified and no application code was changed. Findings: 1 CRITICAL (Constitution III — bundle group undocumented under docs/reference/), 3 HIGH (FR-005/SC-007 validate references; FR-009/SC-002 info expansion; FR-012 install-time init), 3 MEDIUM (FR-013 integration precedence; FR-020 surface overlaps; FR-028 update refresh). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement Phase 8 convergence tasks (T046–T052) Close the gaps the converge command found between the bundler spec/plan/ constitution and the code: - T046: add docs/reference/bundles.md documenting the full `specify bundle` command group; link it from docs/reference/overview.md (Constitution III). - T047: wire a reference checker into `bundle validate` (services/references.py); online runs fail and name unresolved component references, offline runs warn. - T048: expand `bundle info` to enumerate the full component set (versions, preset priority/strategy) plus the bundle integration — info == install. - T049/T050: `bundle install`/`bundle init` now scaffold an uninitialized project via the existing `specify init` machinery, choosing the integration by precedence (override → bundle-declared → Copilot + OS default script type). - T051: surface foreseeable component overlaps during info and install. - T052: `bundle update` refreshes already-installed components via a new refresh path in install_bundle, preserving primitive-level overrides. Adds unit/contract/integration coverage (107 tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * converge: append Phase 9 (T053) — surface bundle trust indicator Re-run of converge after Phase 8. The seven Phase 8 tasks are verified closed. One residual partial gap remains: the `verified`/trust indicator (FR-010, FR-027) is exposed only in `bundle info --json`, absent from `bundle search` (the primary discovery surface) and `bundle info` text. Appended as a single new task for implement to complete. Append-only; no code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement T053 — surface bundle trust indicator in discovery `bundle search` (text + JSON) and `bundle info` (text + JSON) now expose each catalog entry's verification/trust level (verified vs community), so users can judge a bundle's trust before installing, per FR-010 / FR-027. Previously `verified` was only present in `bundle info --json`. Adds contract coverage; 108 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review — annotations, Windows paths, HTTPS, errors, reproducible builds Resolves automated review feedback on github/spec-kit#3070: - validator: drop redundant string-quoting on ReferenceChecker's `str | None` return so the annotation evaluates as a real union under `from __future__ import annotations`. - adapters: normalize Windows drive-letter paths (e.g. C:\...) to the local-file branch so offline file catalogs resolve on Windows. - adapters: enforce HTTPS (HTTP only for localhost) and require a host on remote catalog URLs before any network call, mirroring specify_cli.catalogs URL validation (MITM/downgrade protection). - adapters: pass `origin` to loads_json for local files and HTTP payloads so JSON parse errors name the real source instead of <string>. - manifest: parse component `priority` defensively, raising an actionable BundlerError on non-integer values instead of a raw ValueError. - packager: write zip members with a fixed timestamp + permissions so identical inputs yield byte-for-byte identical artifacts (genuinely reproducible builds), and strengthen the determinism test accordingly. Adds regression tests for priority validation, plain-HTTP/host rejection, and byte-level artifact reproducibility (111 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 2 — nested output dir + file:// URLs - packager: when --output points inside the bundle directory, exclude the whole output subtree from collection so previously-built artifacts are never re-packaged (prevents broken reproducibility and unbounded growth). - adapters: resolve file:// catalog URLs via url2pathname and preserve netloc, so Windows file URLs (file:///C:/...) and UNC shares (file://server/share) resolve correctly instead of dropping the host or producing /C:/x. Adds regression tests for nested-output exclusion and file:// resolution (113 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 3 — discovery UX + hardening - bundle search/info: fall back to the built-in/user catalog stack instead of requiring a Spec Kit project, so discovery works in a fresh directory (and the README/quickstart examples now match actual behavior). install still auto-initializes a project as before. - packager: traverse with os.walk(followlinks=False) and prune symlinked directories before descending, so a symlink-to-dir can no longer pull in out-of-tree files (which previously turned "skip symlinks" into a hard ensure_within() failure and did extra filesystem work). - records: parse contributed-component priority defensively, raising an actionable BundlerError on a corrupt records file instead of leaking a raw ValueError/traceback. - installer: give install_bundle's manifest parameter an explicit BundleManifest | None type for a clearer, safer service API. Adds regression tests for project-less search/info, symlinked-dir pruning, and corrupt-priority records (117 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 4 + markdownlint exclusions Review fixes: - bundle info: expand the manifest regardless of install policy so discovery-only bundles remain inspectable (only install is refused). - _download_manifest: handle local .zip download_url by extracting bundle.yml (via _local_manifest_source), and add a real remote HTTPS fetch path using the shared authenticated, redirect-validated open_url client (HTTPS enforced on the initial URL and every redirect; offline still refuses). - _run_init: thread the --offline flag through to the init callback so `bundle install/init --offline` never performs network init. - conflict.ConflictReport: use field(default_factory=list) and drop the None + __post_init__ workaround. - CatalogSource.from_dict: parse priority defensively, raising an actionable BundlerError naming the source + offending value instead of a raw ValueError. markdownlint: - Exclude .specify/, .github/, and specs/ (and their subdirectories) from markdownlint so the in-flight dogfooding scaffolding doesn't trip the linter. Adds regression tests for discovery-only info, local-zip download_url, and non-integer catalog priority (120 bundler tests pass; ruff clean; the PR's own markdown lints clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 5 + ignore generated files in whitespace check Review fixes: - packager: exclude any prior build artifact for this bundle (matching <id>-*.zip), not just the current output path, so older artifacts next to bundle.yml are never re-packaged. - docs(bundles): correct the note — `search` and `info` work without a project (they fall back to the built-in/user catalog stack); only list/update/remove/ catalog require an initialized project. CI / generated files: - .gitattributes: mark the generated dogfooding scaffolding (.specify/**, the speckit .github agent/prompt files, copilot-instructions.md, specs/**) with -whitespace so `git diff --check` (the Lint workflow's whitespace gate) stops flagging emitted trailing whitespace. These files are produced by `specify init` and are scrubbed before merge. Adds a regression test for prior-artifact exclusion (121 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): collision-resistant catalog ids, canonical local paths, explicit uninstalled result Addresses review round 6 (PR #3070): - catalog_config._derive_id now combines host label with the URL path stem so multiple catalogs from the same host get distinct, stable default ids. - add_source canonicalizes local file paths to absolute before persisting, so project config no longer depends on the caller's cwd. - InstallResult gains a dedicated `uninstalled` list; remove_bundle no longer overloads `installed` for removals, and the CLI prints from `uninstalled`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): confine config writes, guard indeterminate integration, fix validate docs Addresses review round 7 (PR #3070): - save_records and catalog_config._write now pass within=project_root to dump_json/dump_yaml, refusing symlinked .specify paths that escape the project (defense-in-depth, matching the rest of the codebase). - resolve_install_plan now fails when a bundle pins an integration but the project's active integration cannot be determined and no explicit --integration override was given, instead of silently adopting the bundle's required integration (FR-019 guard). CLI passes integration_explicit. - docs/reference/bundles.md: corrected the validate semantics to describe the actual best-effort online behavior (unreachable catalogs warn, not fail). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): Windows path handling + review round 8 hardening Fix Windows CI failures: - is_safe_relpath now rejects POSIX-absolute (/abs) and Windows drive-absolute (C:\x, UNC) paths on every OS, instead of passing them through on Windows where os.path.isabs('/abs') is False and Path('/abs').parts yields '\\'. - _download_manifest treats a Windows drive-letter download_url (C:\bundle.yml, which urlparse reads as scheme 'c') as a local file, fixing the empty component set in `bundle info` on Windows. Address review round 8 (PR #3070): - Bundled workflows now install under --offline (locate via _locate_bundled_workflow) instead of being refused unconditionally. - bundle update preserves the original installed_at timestamp on refresh (import find_record; reuse the existing record's timestamp). - _derive_id lowercases the host label so 'Example.com' and 'example.com' produce the same deterministic id. - CatalogEntry.from_dict validates 'tags' is a list and 'verified' is a real boolean, raising BundlerError on invalid untrusted shapes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): normalize SemVer prerelease spellings before version parsing Addresses review round 9 (PR #3070): parse_version and is_semver now apply the same prerelease normalization (mirroring specify_cli._version._normalize_tag) so SemVer spellings like 1.2.3-rc1 / 1.2.3-alpha1 validate and compare consistently across is_semver, parse_version, and satisfies. Leading 'v' is also stripped. Keeps the manifest validator and constraint checks in agreement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): no collateral removal + enforce manifest-pinned versions Addresses review round 10 (PR #3070): - install_bundle records only the components this bundle actually contributed: freshly-installed components, plus pre-existing ones already owned by this bundle (refresh) or a sibling bundle (shared/refcounted). A component that is installed on disk but tracked by no bundle was installed independently and is no longer attributed, so `bundle remove` won't uninstall it (FR-022). - preset/extension/workflow install paths now verify the active catalog's advertised version matches the manifest-pinned component.version before downloading/installing, raising BundlerError on mismatch so bundles stay reproducible. When a catalog advertises no version the pin can't be enforced and installation proceeds. Added regression tests: independent pre-existing component survives removal; version-mismatch refusal (helper + workflow path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892) * feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root Resolve an explicit SPECIFY_INIT_DIR project override once in the core get_repo_root / Get-RepoRoot, so a non-interactive / CI caller can target a member project (the directory containing .specify/) from a monorepo root without cd. Strict by design: the path must exist and contain .specify/, otherwise it hard-errors with no silent fallback. - Single resolver in core; the git feature-branch script inherits it by sourcing core, with no per-extension copies. - PS resolver verifies the resolved path is a directory (Resolve-Path also succeeds for files) so a file value errors as "not an existing directory". - get_feature_paths splits decl/assignment so a SPECIFY_INIT_DIR failure propagates instead of being masked by `local`. - create-new-feature-branch: when core is absent (only git-common loaded) and SPECIFY_INIT_DIR is set, hard-error rather than silently using the git root. - Document SPECIFY_INIT_DIR and SPECIFY_FEATURE_DIRECTORY in the core reference. - Tests for valid/relative/trailing-slash/file/missing/no-.specify targets, feature-axis composition, the no-core guard, and a PowerShell mirror. * fix: guard SPECIFY_INIT_DIR with stale core scripts * docs: clarify SPECIFY_FEATURE_DIRECTORY precedence wording * fix: normalize trailing slash in PowerShell SPECIFY_INIT_DIR resolver Resolve-Path preserves a trailing separator from its input, so a SPECIFY_INIT_DIR ending in a slash returned a root that didn't match the bash resolver (whose `cd && pwd` strips it). That broke test_ps_trailing_slash_tolerated on the CI runners, which do have pwsh. Trim it with TrimEndingDirectorySeparator (no-op on a bare root or a path with no trailing separator). Also fix the misleading test comment: the PowerShell mirror runs on the CI ubuntu/windows runners (they ship pwsh), it is not skipped there. * test: normalize bash path expectations on Windows * docs: clarify SPECIFY_INIT_DIR root helpers * chore: sync dogfooded .specify core scripts with SPECIFY_INIT_DIR Mirror the SPECIFY_INIT_DIR resolver (resolve_specify_init_dir in common.sh) into the committed dogfooding .specify/scripts/bash copies so the git extension's create-new-feature-branch.sh finds an up-to-date common.sh instead of failing with "requires updated Spec Kit core scripts". Fixes the test_init_dir.py CI failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): harden remote catalog fetch and config parsing - adapters: route catalog HTTP fetches through the shared authenticated client (authentication.http.open_url) so auth.json tokens apply and the Authorization header is stripped on cross-host/downgrade redirects. Reject any redirect that leaves HTTPS via a redirect_validator and re-validate the final URL after redirects, closing the urlopen auto-redirect MITM/downgrade gap. - catalog_config._read: raise an actionable BundlerError when the config top level is not a mapping, 'catalogs' is not a list, or an entry is not a mapping, instead of letting list(<str>) produce a downstream AttributeError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): tighten record read confinement, policy gate, and precedence Addresses review 4534504799: - records.load_records: confine the read via ensure_within(project_root, ...) so a symlinked/traversal-escaping .specify cannot read arbitrary files outside the project (matches the write path's within= guard). - catalog_config._slug: lowercase so derived catalog ids are deterministic across platforms and case-variant duplicates can't slip past the case-sensitive dup check. - installer.install_bundle: reword the docstring's misleading "atomic on failure" claim to describe the real scoped guarantee (record written only on full success; rollback limited to newly-installed components). - bundle update: enforce the source install_policy like install, refusing to update from a discovery-only source (FR-025). - catalog source precedence: the CLI now passes ~/.specify as the user config dir so project > user > built-in precedence is actually reachable (previously the user scope was silently ignored). - .gitattributes: scope the specs whitespace exemption to the generated dogfooding feature dir (specs/001-spec-kit-bundler/**) instead of all of specs/**. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): no collateral refresh, catalog id integrity, loud info Addresses review 4534571362: - installer: in refresh mode (bundle update) only re-apply already- installed components that this bundle (or a sibling) owns. Components installed independently and tracked by no bundle are now skipped, never refreshed, so update cannot make collateral changes (FR-022). - catalog.load_catalog_payload: validate each entry's own id is present and matches its enclosing bundles key, rejecting catalogs that would otherwise list a spoofed or unresolvable id. - bundle info: stop swallowing manifest download failures. If the manifest can't be resolved (e.g. --offline against an https download_url or a download failure), surface the error and exit non-zero instead of silently degrading to catalog `provides` counts, preserving the "info == what install applies" guarantee. Added regressions: refresh leaves independently-installed components untouched, catalog id key/field mismatch + missing id rejection, and info exits non-zero when the manifest is unresolvable offline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): confine catalog-config and integration-marker reads Addresses review 4534716790: two more state reads bypassed the symlink/path-escape confinement that records and the write paths already enforce. - catalog_config._read: validate the config path with ensure_within(project_root, ...) before exists()/read, so a symlinked .specify resolving outside project_root is rejected instead of read. - lib.project.active_integration: confine the .specify/integration.json read the same way; an out-of-tree escape is treated as "not determinable" (returns None) rather than followed. Added regressions covering both via a symlinked .specify pointing outside the project root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate manifest tags, disambiguate derived ids by full host Addresses review 4534768419: - manifest.from_dict: reject a non-list `tags` (e.g. a bare string) instead of splitting it character-by-character, matching the catalog parser and the schema contract (tags = list of strings). - catalog_config._derive_id: derive ids from the full host (TLD included) so example.com and example.net no longer collide on the same id. Updated the affected id assertions. - CHANGELOG: call out the new `specify bundle` command group in the unreleased section (the PR's headline user-facing feature). - .gitattributes: clarify the specs whitespace exemption — the dogfooding feature dir is scrubbed before merge (not retained), so it doesn't weaken checks for kept docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(gitattributes): retain whitespace exemption for constitution.md The project constitution (.specify/memory/constitution.md) is the one dogfooding artifact carried forward past the pre-merge scrub. Give it its own standalone whitespace exemption so it survives removal of the broader .specify/** generated-scaffolding exemption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): accurate uninstall count, confine catalog read, safe bundle id Addresses review 4534812056: - installer.remove_bundle: only count a component as uninstalled when installer.remove() actually ran; components already absent on disk are reported as skipped, keeping the uninstalled count accurate. - catalog.load_source_stack: confine the project-scoped .specify config read with ensure_within, so a symlinked .specify/ resolving outside the project root is refused (consistent with the bundler's other guarded reads). - manifest: enforce a filesystem-safe slug for bundle.id in structural validation; packager.build_bundle adds an ensure_within defense-in-depth check so a crafted id can never push the artifact outside the output dir. Also reverts the CHANGELOG entry (the changelog is updated separately). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate requires/provides shapes in manifest and catalog Addresses review 4534855443: - manifest: validate requires.tools and requires.mcp as list-of-strings via a shared _parse_str_list helper (also reused for tags), so a bare string like `tools: docker` is rejected with an actionable BundlerError instead of being split character-by-character. - catalog.CatalogEntry.from_dict: validate that `requires` and `provides` are mappings before accessing them, so an untrusted catalog payload with `requires: "..."` raises a named BundlerError rather than escaping as a raw AttributeError traceback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): require README.md when building a bundle artifact Addresses review 4534938014: build_bundle now fails early with an actionable error when README.md is missing, matching the documented artifact contract (manifest + README) instead of silently producing a bundle with no human-facing description. Also reverts CHANGELOG.md to the upstream/main copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate record shapes; drop stale install --refresh claim Addresses review 4534969692: - records.InstalledBundleRecord.from_dict: hard-error when contributed_components is not a list, instead of iterating a corrupt bare string character-by-character. - records.load_records: validate the top-level 'bundles' field is a list and fail with a clear BundlerError when a corrupt file makes it a mapping/string. - PR description: remove the inaccurate "supports --refresh" note from `bundle install` (refresh is the `bundle update` path); docs already omit it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): refuse symlinked .specify, reject bad url schemes, IPv6 ids Addresses review 4534997724: - lib.project.find_project_root: a symlinked .specify is no longer accepted as a project root (is_dir() follows symlinks), matching the confinement the rest of the CLI applies and avoiding confusing downstream failures. - catalog_config.add_source: reject unsupported url schemes (ssh://, ftp://, ...) up front instead of silently treating them as local paths; local paths containing ':' but not '://' are still allowed. - catalog_config._derive_id: derive the host via urlparse().hostname so IPv6 literals, credentials, and ports no longer corrupt the derived id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): strict semver, narrow artifact skip, preserve priority 0 Addresses review 4535084048: - versioning.is_semver: enforce a full MAJOR.MINOR.PATCH SemVer (with optional pre-release/build) via a dedicated regex, instead of accepting any packaging.version.Version-parseable string (e.g. "1", "1.0"). This makes BundleManifest.structural_errors() reject non-semver versions. - packager: narrow the prior-artifact skip pattern to semver-named zips (<id>-<x.y.z>.zip) so legitimate assets like <id>-assets.zip are still packaged. - primitives (preset + extension install): use an explicit `is None` check so an intentional priority of 0 is preserved instead of being replaced by the default. Adds regressions: non-semver rejection ("1"/"1.0"/"1.2.3.4"), asset-not- excluded vs semver-artifact-excluded, and priority-0 pass-through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): artifact regex for prerelease+build; clarify integration/priority docs Addresses review 4535132279: - packager: the prior-artifact skip regex now matches semver names carrying both a prerelease and build-metadata segment (e.g. 1.0.0-rc1+build5), so such an existing artifact is excluded rather than re-packaged — keeping builds bounded/deterministic, consistent with is_semver(). - docs/reference/bundles.md: correct the install integration wording. --integration selects the integration when initializing a new project and confirms the target when a pinned bundle's active integration can't be determined; it does NOT override a bundle that targets a specific integration (a mismatch aborts with no changes). - examples/security-researcher README: reword the preset priority note in terms of the numeric comparison (ascending priority order) to avoid inverting the meaning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): --integration can't bypass clash guard; honest rollback docs Addresses review 4535159341: - bundle install: for an already-initialized project, the project's recorded active integration is now authoritative. --integration no longer overrides it (which let a copilot project install a claude-pinned bundle via `--integration claude`, bypassing the FR-019 clash guard). The override still selects the integration at init time and confirms the target only when the active integration cannot be determined. - docs/reference/bundles.md: reword the install guarantee to match the implementation — no provenance record is written unless the install fully succeeds, and rollback of this run's components is best-effort (removal errors are swallowed, so partial on-disk state may remain). Dropped the inaccurate "atomic / rolls back everything" claim. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate component kind/id when loading records Addresses review 4535194606: _component_from_dict now rejects a contributed component whose 'kind' is not a supported component kind or whose 'id' is empty, raising a BundlerError that explicitly flags the records file as corrupt. Previously such a record loaded successfully and only failed later (e.g. in primitive_manager() during bundle remove/update) with a less actionable error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address review 4535234003 (7 findings) - versioning: tolerate an uppercase `V` prefix in `_normalize_semver` and `is_semver`, mirroring specify_cli._version tag normalization (V -> v) so `V1.2.3` parses and validates consistently. - validator: import BundlerError and narrow the speckit_version constraint except clause to `BundlerError` only, so programming errors are no longer masked behind an "invalid constraint" message. - bundle update: accept `--integration` and thread it through resolve_install_plan the same way `bundle install` does (override used only when the active integration can't be auto-detected), so integration-pinned bundles can be updated where `.specify/integration.json` is missing/unreadable. - bundle validate: fold reference warnings into `report.warnings` so the ValidationReport is the single warning channel at the CLI layer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(bundler): make update --integration help assertion ANSI-safe Rich can split the "--integration" option label with ANSI escape codes between the two leading dashes, so the literal substring check failed under CI's terminal settings. Match the un-split option word instead, mirroring how test_bundle_help_lists_all_commands checks bare command names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): preserve exec bits in artifacts; document install-time pins Addresses review 4535280786: - packager.build_bundle: no longer forces every ZIP member to 0644, which stripped the executable bit from bundled scripts (e.g. extension hook scripts) and could break them after extraction. Permissions are now normalized reproducibly to 0755 when the source file has any execute bit set, otherwise 0644 — identical inputs still yield byte-for-byte identical artifacts. - installer.install_bundle + docs/reference/bundles.md: document that version pins are enforced install-time only. Because primitive is_installed checks are id-based (not version-aware), an already-present component is skipped during install without comparing its on-disk version to the manifest pin; pins are guaranteed applied only on a real install or `bundle update` refresh. Added a regression asserting executable sources map to 0755 and plain files to 0644 in the built artifact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(bundler): skip exec-bit packager test on Windows Windows filesystems do not carry Unix execute bits, so chmod(0o755) is a no-op and the source file reports no execute bit — the packager then correctly stores the member as 0644. The assertion that an executable source maps to 0755 is only meaningful on POSIX, so skip it on nt rather than asserting platform-specific behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): normalize prerelease spellings inside version constraints Addresses review 4535327154: parse_version() normalized SemVer prerelease spellings (e.g. 1.2.3-rc1 -> 1.2.3rc1) but parse_constraint() passed the constraint to packaging.SpecifierSet unmodified, so ">=1.2.3-rc1" raised InvalidSpecifier even though the same spelling is accepted for installed versions. parse_constraint() now normalizes the version portion of each comma-separated clause via the shared _normalize_semver helper, so prerelease handling is consistent across versions and constraints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate schema versions and required record identity fields Addresses review 4535351596: - records.load_records: validate the on-disk 'schema_version' (required; forward-compatible across same-major minor bumps) and fail fast with an actionable error on a missing/unknown version, rather than silently parsing a possibly-incompatible format and risking incorrect bundle attribution/removal. - records.InstalledBundleRecord.from_dict: treat missing 'bundle_id' or 'version' as corruption and raise BundlerError, instead of coercing them to empty strings that let later list/remove/update operations behave unpredictably. - catalog_config._read: validate 'schema_version' when present (same-major compatibility) and fail fast on an unsupported version so an incompatible future config shape can't be mis-parsed into a wrong effective catalog stack. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(bundler): scrub generated dogfooding scaffold before merge The bundler feature was developed by dogfooding Spec Kit on itself. Now that the work is complete, remove all generated scaffolding so it does not land in the repository on merge: - specs/001-spec-kit-bundler/** (spec, plan, research, data-model, contracts, quickstart, tasks, checklists) - .specify/** (extensions, integrations, scripts, templates, workflows, feature/init/integration metadata) - .github/agents/speckit.*.agent.md, .github/prompts/speckit.*.prompt.md, and .github/copilot-instructions.md (Copilot integration scaffold) Retained: .specify/memory/constitution.md — the single dogfooding artifact carried forward — with its whitespace exemption in .gitattributes. .gitattributes and .markdownlint-cli2.jsonc are reverted to the upstream baseline (plus the constitution whitespace exemption), dropping the now-moot exemptions for the removed scaffold. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Pascal THUET <pascal.thuet@arte.tv>
This commit is contained in:
125
tests/bundler_helpers.py
Normal file
125
tests/bundler_helpers.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Shared helpers and fakes for bundler tests.
|
||||
|
||||
Kept out of ``tests/conftest.py`` so the existing root fixtures are untouched.
|
||||
Import what you need explicitly, e.g.::
|
||||
|
||||
from tests.bundler_helpers import FakeInstaller, write_manifest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
|
||||
|
||||
def valid_manifest_dict(**overrides) -> dict:
|
||||
"""Return a structurally valid manifest dict; override any top-level key."""
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "demo-bundle",
|
||||
"name": "Demo Bundle",
|
||||
"version": "1.2.0",
|
||||
"role": "developer",
|
||||
"description": "A demo bundle for tests.",
|
||||
"author": "Spec Kit",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"extensions": [{"id": "ext-a", "version": "1.0.0"}],
|
||||
"presets": [
|
||||
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
|
||||
],
|
||||
"steps": [{"id": "step-a"}],
|
||||
"workflows": [{"id": "wf-a", "version": "0.3.0"}],
|
||||
},
|
||||
"tags": ["demo", "test"],
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def write_manifest(directory: Path, data: dict | None = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = directory / "bundle.yml"
|
||||
manifest_path.write_text(
|
||||
yaml.safe_dump(data if data is not None else valid_manifest_dict()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return manifest_path
|
||||
|
||||
|
||||
def make_project(root: Path) -> Path:
|
||||
"""Create a minimal Spec Kit project skeleton under *root*."""
|
||||
(root / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def catalog_payload(bundles: dict | None = None) -> dict:
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-19T00:00:00Z",
|
||||
"catalog_url": "file://test",
|
||||
"bundles": bundles or {},
|
||||
}
|
||||
|
||||
|
||||
def catalog_entry_dict(bundle_id: str = "demo-bundle", **overrides) -> dict:
|
||||
entry = {
|
||||
"id": bundle_id,
|
||||
"name": "Demo Bundle",
|
||||
"version": "1.2.0",
|
||||
"role": "developer",
|
||||
"description": "A demo bundle.",
|
||||
"author": "Spec Kit",
|
||||
"license": "MIT",
|
||||
"download_url": "",
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"extensions": 1, "presets": 1, "steps": 1, "workflows": 1},
|
||||
"verified": True,
|
||||
}
|
||||
entry.update(overrides)
|
||||
return entry
|
||||
|
||||
|
||||
def write_catalog_file(path: Path, bundles: dict) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(catalog_payload(bundles)), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class FakeInstaller:
|
||||
"""Deterministic in-memory PrimitiveInstaller for offline integration tests."""
|
||||
|
||||
def __init__(self, *, fail_on: str | None = None) -> None:
|
||||
self.installed: set[tuple[str, str]] = set()
|
||||
self.install_calls: list[tuple[str, str]] = []
|
||||
self.remove_calls: list[tuple[str, str]] = []
|
||||
self.refresh_calls: list[tuple[str, str]] = []
|
||||
self._fail_on = fail_on
|
||||
|
||||
def _key(self, component: ComponentRef) -> tuple[str, str]:
|
||||
return (component.kind, component.id)
|
||||
|
||||
def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
|
||||
return self._key(component) in self.installed
|
||||
|
||||
def install(self, project_root: Path, component: ComponentRef) -> None:
|
||||
from specify_cli.bundler import BundlerError
|
||||
|
||||
self.install_calls.append(self._key(component))
|
||||
if self._fail_on is not None and component.id == self._fail_on:
|
||||
raise BundlerError(f"Simulated failure installing {component.id}")
|
||||
self.installed.add(self._key(component))
|
||||
|
||||
def remove(self, project_root: Path, component: ComponentRef) -> None:
|
||||
self.remove_calls.append(self._key(component))
|
||||
self.installed.discard(self._key(component))
|
||||
|
||||
def refresh(self, project_root: Path, component: ComponentRef) -> None:
|
||||
self.refresh_calls.append(self._key(component))
|
||||
self.installed.add(self._key(component))
|
||||
391
tests/contract/test_bundle_cli.py
Normal file
391
tests/contract/test_bundle_cli.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Contract test for the `specify bundle` CLI surface (Typer integration).
|
||||
|
||||
Exercises the wired commands end-to-end via CliRunner against a temp project,
|
||||
asserting exit codes and the cross-cutting error guarantees from
|
||||
contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import (
|
||||
catalog_entry_dict,
|
||||
valid_manifest_dict,
|
||||
write_catalog_file,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project(tmp_path: Path, monkeypatch) -> Path:
|
||||
(tmp_path / ".specify").mkdir()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_bundle_help_lists_all_commands():
|
||||
result = runner.invoke(app, ["bundle", "--help"])
|
||||
assert result.exit_code == 0
|
||||
for cmd in ("search", "info", "list", "install", "update", "remove",
|
||||
"validate", "build", "init", "catalog"):
|
||||
assert cmd in result.output
|
||||
|
||||
|
||||
def test_update_accepts_integration_override():
|
||||
# Update must expose --integration so integration-pinned bundles can be
|
||||
# updated in projects where the active integration can't be auto-detected.
|
||||
# Rich may insert ANSI escapes between the two leading dashes, so match the
|
||||
# un-split option word rather than the literal "--integration".
|
||||
result = runner.invoke(app, ["bundle", "update", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "integration" in result.output
|
||||
|
||||
|
||||
def test_list_empty_project(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No bundles installed" in result.output
|
||||
|
||||
|
||||
def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 1
|
||||
assert "Spec Kit project" in result.output
|
||||
|
||||
|
||||
def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
|
||||
# Discovery commands fall back to the built-in/user catalog stack and must
|
||||
# not require a Spec Kit project (matches README/quickstart examples).
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.strip().startswith("[")
|
||||
|
||||
|
||||
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
|
||||
# Reaches catalog resolution (not the project gate) and reports a clean miss.
|
||||
assert result.exit_code == 1
|
||||
assert "Spec Kit project" not in result.output
|
||||
|
||||
|
||||
def test_catalog_list_shows_builtin_defaults(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "catalog", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "default" in result.output
|
||||
assert "community" in result.output
|
||||
assert "built-in default stack" in result.output
|
||||
|
||||
|
||||
def test_catalog_add_and_remove(project: Path):
|
||||
catalog = project / "local-catalog.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
listed = runner.invoke(app, ["bundle", "catalog", "list"])
|
||||
assert "local" in listed.output
|
||||
|
||||
removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"])
|
||||
assert removed.exit_code == 0
|
||||
|
||||
|
||||
def test_catalog_remove_builtin_is_refused(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "catalog", "remove", "default"])
|
||||
assert result.exit_code == 1
|
||||
assert "built-in" in result.output
|
||||
|
||||
|
||||
def test_validate_reports_invalid_manifest(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 1
|
||||
assert "license" in result.output
|
||||
|
||||
|
||||
def test_validate_accepts_valid_manifest(project: Path):
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
# Offline mode does not fail on references it cannot verify (synthetic ids
|
||||
# here); they surface as warnings while structure is confirmed valid.
|
||||
result = runner.invoke(app, ["bundle", "validate", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "valid" in result.output
|
||||
|
||||
|
||||
def test_validate_rejects_broken_reference(project: Path):
|
||||
# Synthetic component ids resolve to nothing in any catalog → hard failure.
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 1
|
||||
assert "preset-a" in result.output or "ext-a" in result.output
|
||||
|
||||
|
||||
def test_validate_accepts_bundled_reference(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "valid" in result.output
|
||||
|
||||
|
||||
def test_build_produces_artifact(project: Path):
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(project / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")])
|
||||
assert result.exit_code == 0, result.output
|
||||
artifacts = list((project / "dist").glob("*.zip"))
|
||||
assert len(artifacts) == 1
|
||||
|
||||
|
||||
def test_info_expands_full_component_set(project: Path):
|
||||
bundle_dir = project / "src-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
catalog = project / "local-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]): c for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
preset = components[("presets", "preset-a")]
|
||||
assert preset["version"] == "2.0.0"
|
||||
assert preset["priority"] == 10
|
||||
assert preset["strategy"] == "append"
|
||||
assert payload["trust"] == "verified"
|
||||
|
||||
text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
|
||||
assert "preset-a v2.0.0" in text.output
|
||||
assert "Trust" in text.output
|
||||
|
||||
|
||||
def test_info_expands_discovery_only_bundle(project: Path):
|
||||
# Discovery-only bundles must still be fully inspectable via `info`;
|
||||
# only `install` is refused for them.
|
||||
bundle_dir = project / "disc-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
catalog = project / "disc-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]) for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_info_resolves_local_zip_download_url(project: Path):
|
||||
# A local .zip artifact as download_url is extracted to read bundle.yml.
|
||||
bundle_dir = project / "zip-src"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(bundle_dir / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path
|
||||
catalog = project / "zip-catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
|
||||
)
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]) for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_install_refuses_discovery_only_source(project: Path, monkeypatch):
|
||||
# Point a discovery-only catalog at a local payload containing the bundle.
|
||||
catalog = project / "disc.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "install", "demo", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "discovery-only" in result.output
|
||||
|
||||
|
||||
def test_update_refuses_discovery_only_source(project: Path):
|
||||
# An installed bundle whose only resolvable source is discovery-only must
|
||||
# not be updatable from there (FR-025), mirroring the install policy gate.
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.models.records import (
|
||||
InstalledBundleRecord,
|
||||
save_records,
|
||||
)
|
||||
|
||||
save_records(
|
||||
project,
|
||||
[
|
||||
InstalledBundleRecord.create(
|
||||
"demo",
|
||||
"1.0.0",
|
||||
[ComponentRef(kind="extensions", id="ext-a", version=None)],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
catalog = project / "disc.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["bundle", "update", "demo", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "discovery-only" in result.output
|
||||
|
||||
|
||||
def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path):
|
||||
# `info` must expand the real component set; if the manifest can't be
|
||||
# resolved (here: --offline against an https download_url), it should error
|
||||
# and exit non-zero rather than silently degrading to `provides` counts.
|
||||
catalog = project / "remote-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "Network access disabled" in result.output
|
||||
|
||||
|
||||
def test_search_json_offline(project: Path):
|
||||
catalog = project / "c.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "c", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "install-allowed"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload[0]["id"] == "demo"
|
||||
# Trust indicator is exposed on the discovery surface (FR-010 / FR-027).
|
||||
assert payload[0]["verified"] is True
|
||||
assert payload[0]["trust"] == "verified"
|
||||
|
||||
|
||||
def test_search_text_shows_trust(project: Path):
|
||||
catalog = project / "c.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{
|
||||
"verified-one": catalog_entry_dict("verified-one", verified=True),
|
||||
"community-one": catalog_entry_dict("community-one", verified=False),
|
||||
},
|
||||
)
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "c", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "install-allowed"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "verified" in result.output
|
||||
assert "community" in result.output
|
||||
|
||||
|
||||
def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
|
||||
# An initialized project's recorded active integration is authoritative:
|
||||
# passing --integration must not let a differently-pinned bundle install.
|
||||
import json
|
||||
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({"integration": "copilot"}), encoding="utf-8"
|
||||
)
|
||||
bundle_dir = project / "claude-bundle"
|
||||
bundle_dir.mkdir()
|
||||
data = valid_manifest_dict(integration={"id": "claude"})
|
||||
(bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "claude" in result.output and "copilot" in result.output
|
||||
147
tests/contract/test_catalog_schema.py
Normal file
147
tests/contract/test_catalog_schema.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Contract tests for the catalog schema and source stack.
|
||||
|
||||
Mirrors contracts/bundle-catalog.schema.md: source precedence project > user >
|
||||
built-in, install policy gating, payload parsing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.models.catalog import (
|
||||
BUILTIN_DEFAULT_STACK,
|
||||
CatalogSource,
|
||||
InstallPolicy,
|
||||
Scope,
|
||||
load_catalog_payload,
|
||||
load_source_stack,
|
||||
)
|
||||
from specify_cli.bundler import BundlerError
|
||||
import pytest
|
||||
from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project
|
||||
|
||||
|
||||
def test_non_integer_source_priority_raises_actionable_error():
|
||||
with pytest.raises(BundlerError, match="non-integer priority"):
|
||||
CatalogSource.from_dict(
|
||||
{"id": "corp", "url": "https://corp/catalog.json", "priority": "high"},
|
||||
Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_default_stack_when_no_config(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
sources = load_source_stack(tmp_path)
|
||||
ids = [s.id for s in sources]
|
||||
assert ids == ["default", "community"]
|
||||
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
|
||||
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
|
||||
assert all(s.scope is Scope.BUILTIN for s in sources)
|
||||
|
||||
|
||||
def test_project_config_overrides_same_id(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "default", "url": "file://local", "priority": 1,
|
||||
"install_policy": "install-allowed"},
|
||||
{"id": "corp", "url": "https://corp/catalog.json", "priority": 0,
|
||||
"install_policy": "install-allowed"},
|
||||
],
|
||||
}
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
sources = load_source_stack(tmp_path)
|
||||
by_id = {s.id: s for s in sources}
|
||||
assert by_id["default"].scope is Scope.PROJECT
|
||||
assert by_id["default"].url == "file://local"
|
||||
# Highest precedence (lowest priority number) sorts first.
|
||||
assert sources[0].id == "corp"
|
||||
|
||||
|
||||
def test_user_scope_between_builtin_and_project(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
user_dir = tmp_path / "userconf"
|
||||
user_dir.mkdir()
|
||||
(user_dir / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"catalogs": [
|
||||
{"id": "community", "url": "https://u", "priority": 2,
|
||||
"install_policy": "install-allowed"}
|
||||
]}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sources = load_source_stack(tmp_path, user_config_dir=user_dir)
|
||||
by_id = {s.id: s for s in sources}
|
||||
# User overrode the built-in community policy to install-allowed.
|
||||
assert by_id["community"].scope is Scope.USER
|
||||
assert by_id["community"].install_allowed is True
|
||||
|
||||
|
||||
def test_load_payload_parses_entries():
|
||||
payload = catalog_payload({"demo-bundle": catalog_entry_dict()})
|
||||
entries = load_catalog_payload(payload)
|
||||
assert "demo-bundle" in entries
|
||||
assert entries["demo-bundle"].version == "1.2.0"
|
||||
assert entries["demo-bundle"].provides["presets"] == 1
|
||||
|
||||
|
||||
def test_builtin_default_stack_constant_shape():
|
||||
ids = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
|
||||
assert ids == {"default", "community"}
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_string_tags():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["tags"] = "not-a-list"
|
||||
with pytest.raises(BundlerError, match="'tags' must be a list"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_boolean_verified():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["verified"] = "false" # truthy string must not mark the entry verified
|
||||
with pytest.raises(BundlerError, match="'verified' must be a boolean"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_load_payload_rejects_id_key_mismatch():
|
||||
# The enclosing key is authoritative; an entry whose own id disagrees with
|
||||
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.
|
||||
payload = catalog_payload({"demo-bundle": catalog_entry_dict("other-id")})
|
||||
with pytest.raises(BundlerError, match="id mismatch"):
|
||||
load_catalog_payload(payload)
|
||||
|
||||
|
||||
def test_load_payload_rejects_missing_entry_id():
|
||||
entry = catalog_entry_dict("demo-bundle")
|
||||
entry["id"] = ""
|
||||
payload = catalog_payload({"demo-bundle": entry})
|
||||
with pytest.raises(BundlerError, match="missing its 'id'"):
|
||||
load_catalog_payload(payload)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_mapping_requires():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["requires"] = "speckit>=0.1"
|
||||
with pytest.raises(BundlerError, match="'requires' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_mapping_provides():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["provides"] = "extensions"
|
||||
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
126
tests/contract/test_manifest_schema.py
Normal file
126
tests/contract/test_manifest_schema.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Contract tests for the bundle manifest schema (bundle.yml).
|
||||
|
||||
Mirrors contracts/bundle-manifest.schema.md: required identity/metadata fields,
|
||||
semver pinning of components, preset priority+strategy, integration optionality.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def test_valid_manifest_has_no_structural_errors():
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
assert manifest.structural_errors() == []
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
assert manifest.is_agnostic() is True
|
||||
|
||||
|
||||
def test_missing_required_field_is_reported_by_name():
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("bundle.license" in e for e in errors)
|
||||
|
||||
|
||||
def test_unsupported_schema_version_is_rejected():
|
||||
data = valid_manifest_dict(schema_version="9.9")
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("schema_version" in e for e in errors)
|
||||
|
||||
|
||||
def test_non_semver_bundle_version_is_rejected():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["version"] = "not-a-version"
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("semver" in e for e in errors)
|
||||
|
||||
|
||||
def test_preset_requires_priority_and_strategy():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"] = [{"id": "p", "version": "1.0.0"}]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("priority" in e for e in errors)
|
||||
assert any("strategy" in e for e in errors)
|
||||
|
||||
|
||||
def test_invalid_preset_strategy_is_rejected():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"][0]["strategy"] = "merge"
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("strategy" in e for e in errors)
|
||||
|
||||
|
||||
def test_non_integer_priority_raises_actionable_error():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"][0]["priority"] = "high"
|
||||
with pytest.raises(BundlerError, match="priority must be an integer"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_non_step_components_must_be_pinned():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["extensions"] = [{"id": "ext-unpinned"}]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("must be pinned" in e for e in errors)
|
||||
|
||||
|
||||
def test_steps_may_be_unpinned():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["steps"] = [{"id": "step-x"}]
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.structural_errors() == []
|
||||
|
||||
|
||||
def test_integration_makes_bundle_non_agnostic():
|
||||
data = valid_manifest_dict(integration={"id": "copilot"})
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.is_agnostic() is False
|
||||
assert manifest.integration.id == "copilot"
|
||||
|
||||
|
||||
def test_components_property_orders_by_kind():
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
kinds = [c.kind for c in manifest.components]
|
||||
assert kinds == ["extensions", "presets", "steps", "workflows"]
|
||||
|
||||
|
||||
def test_string_tags_rejected_not_split_per_character():
|
||||
# A bare string would otherwise be iterated character-by-character; the
|
||||
# schema requires a list of strings.
|
||||
data = valid_manifest_dict()
|
||||
data["tags"] = "security"
|
||||
with pytest.raises(BundlerError, match="'tags' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_unsafe_bundle_id_flagged_by_structural_validation():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "../evil"
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
errors = manifest.structural_errors()
|
||||
assert any("bundle.id" in e and "slug" in e for e in errors)
|
||||
|
||||
|
||||
def test_valid_slug_bundle_id_passes():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "team-a.bundle_1"
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert not any("bundle.id" in e for e in manifest.structural_errors())
|
||||
|
||||
|
||||
def test_string_tools_rejected_not_split_per_character():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["tools"] = "docker"
|
||||
with pytest.raises(BundlerError, match="'requires.tools' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_string_mcp_rejected_not_split_per_character():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["mcp"] = "github"
|
||||
with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
79
tests/integration/test_bundler_catalog_stack.py
Normal file
79
tests/integration/test_bundler_catalog_stack.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Integration tests for the catalog stack: precedence, policy gating, search."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
|
||||
from specify_cli.bundler.services.catalog_stack import CatalogStack
|
||||
from tests.bundler_helpers import catalog_entry_dict, catalog_payload
|
||||
|
||||
|
||||
def _source(source_id, priority, policy, url="builtin://x"):
|
||||
return CatalogSource(
|
||||
id=source_id, url=url, priority=priority,
|
||||
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def _stack(sources, payloads):
|
||||
def fetcher(src):
|
||||
return payloads[src.id]
|
||||
return CatalogStack(sources, fetcher)
|
||||
|
||||
|
||||
def test_resolve_prefers_highest_precedence_source():
|
||||
sources = [
|
||||
_source("low", 2, "install-allowed"),
|
||||
_source("high", 1, "discovery-only"),
|
||||
]
|
||||
payloads = {
|
||||
"high": catalog_payload({"b": catalog_entry_dict("b", version="9.0.0")}),
|
||||
"low": catalog_payload({"b": catalog_entry_dict("b", version="1.0.0")}),
|
||||
}
|
||||
resolved = _stack(sources, payloads).resolve("b")
|
||||
assert resolved.source.id == "high"
|
||||
assert resolved.entry.version == "9.0.0"
|
||||
assert resolved.install_allowed is False
|
||||
|
||||
|
||||
def test_resolve_unknown_bundle_errors():
|
||||
stack = _stack(
|
||||
[_source("only", 1, "install-allowed")],
|
||||
{"only": catalog_payload({})},
|
||||
)
|
||||
with pytest.raises(BundlerError, match="not found"):
|
||||
stack.resolve("missing")
|
||||
|
||||
|
||||
def test_search_dedupes_by_precedence_and_filters():
|
||||
sources = [_source("a", 1, "install-allowed"), _source("b", 2, "install-allowed")]
|
||||
payloads = {
|
||||
"a": catalog_payload({
|
||||
"alpha": catalog_entry_dict("alpha", role="developer"),
|
||||
}),
|
||||
"b": catalog_payload({
|
||||
"alpha": catalog_entry_dict("alpha", version="0.0.1"),
|
||||
"beta": catalog_entry_dict("beta", role="qa"),
|
||||
}),
|
||||
}
|
||||
stack = _stack(sources, payloads)
|
||||
|
||||
all_results = stack.search()
|
||||
ids = [r.entry.id for r in all_results]
|
||||
assert ids == ["alpha", "beta"]
|
||||
# alpha resolved from the higher-precedence source 'a'.
|
||||
alpha = next(r for r in all_results if r.entry.id == "alpha")
|
||||
assert alpha.source.id == "a"
|
||||
|
||||
qa_only = stack.search("qa")
|
||||
assert [r.entry.id for r in qa_only] == ["beta"]
|
||||
|
||||
|
||||
def test_unreachable_source_raises_named_error():
|
||||
def fetcher(src):
|
||||
raise RuntimeError("boom")
|
||||
stack = CatalogStack([_source("bad", 1, "install-allowed")], fetcher)
|
||||
with pytest.raises(BundlerError, match="bad"):
|
||||
stack.resolve("anything")
|
||||
92
tests/integration/test_bundler_init_install.py
Normal file
92
tests/integration/test_bundler_init_install.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Install-time initialization and integration precedence (T049, T050).
|
||||
|
||||
``specify bundle install`` into an uninitialized directory must scaffold a Spec
|
||||
Kit project first (FR-012), choosing the integration by precedence (FR-013):
|
||||
explicit ``--integration`` override → bundle-declared integration → default.
|
||||
The end-to-end test runs fully offline against bundled assets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.commands.bundle import _resolve_init_integration
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _manifest(**overrides):
|
||||
data = valid_manifest_dict(**overrides)
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_precedence_override_wins():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
assert _resolve_init_integration("gemini", manifest) == "gemini"
|
||||
|
||||
|
||||
def test_precedence_bundle_declared_when_no_override():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
assert _resolve_init_integration(None, manifest) == "claude"
|
||||
|
||||
|
||||
def test_precedence_default_when_unspecified():
|
||||
manifest = _manifest()
|
||||
assert _resolve_init_integration(None, manifest) == "copilot"
|
||||
assert _resolve_init_integration(None, None) == "copilot"
|
||||
|
||||
|
||||
def _build_mini(tmp_path: Path) -> Path:
|
||||
bundle = tmp_path / "mini"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "mini",
|
||||
"name": "Mini",
|
||||
"version": "1.0.0",
|
||||
"role": "developer",
|
||||
"description": "minimal",
|
||||
"author": "tests",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle / "README.md").write_text("# Mini\n", encoding="utf-8")
|
||||
return build_bundle(bundle).artifact_path
|
||||
|
||||
|
||||
def test_install_initializes_uninitialized_project(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
artifact = _build_mini(tmp_path)
|
||||
|
||||
previous = Path.cwd()
|
||||
os.chdir(project)
|
||||
try:
|
||||
result = runner.invoke(
|
||||
app, ["bundle", "install", str(artifact), "--offline"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
assert (project / ".specify").is_dir()
|
||||
marker = project / ".specify" / "integration.json"
|
||||
assert marker.exists()
|
||||
data = json.loads(marker.read_text(encoding="utf-8"))
|
||||
assert "copilot" in json.dumps(data)
|
||||
222
tests/integration/test_bundler_install_flow.py
Normal file
222
tests/integration/test_bundler_install_flow.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Integration tests for the install → record → remove lifecycle (offline, fake installer).
|
||||
|
||||
Uses :class:`FakeInstaller` so no network or real primitive machinery is touched
|
||||
(Constitution Principle II network-mocking, Principle IV offline-first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
|
||||
|
||||
|
||||
def _plan(manifest):
|
||||
return resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_install_records_and_invokes_primitives(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
result = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
assert len(result.installed) == 4
|
||||
assert len(installer.install_calls) == 4
|
||||
records = load_records(tmp_path)
|
||||
assert len(records) == 1
|
||||
assert records[0].bundle_id == "demo-bundle"
|
||||
|
||||
|
||||
def test_install_is_idempotent(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
second = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# Second install adds nothing and does not duplicate the record.
|
||||
assert second.installed == []
|
||||
assert len(second.skipped) == 4
|
||||
assert len(load_records(tmp_path)) == 1
|
||||
|
||||
|
||||
def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller(fail_on="preset-a")
|
||||
|
||||
with pytest.raises(BundlerError):
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# ext-a was installed first, then rolled back; no record persisted.
|
||||
assert installer.installed == set()
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
def test_remove_is_non_collateral(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
# Bundle A provides a shared preset; Bundle B also provides it.
|
||||
data_a = valid_manifest_dict()
|
||||
data_a["bundle"]["id"] = "a"
|
||||
data_b = valid_manifest_dict()
|
||||
data_b["bundle"]["id"] = "b"
|
||||
data_b["provides"] = {"presets": [
|
||||
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
|
||||
]}
|
||||
|
||||
man_a = BundleManifest.from_dict(data_a)
|
||||
man_b = BundleManifest.from_dict(data_b)
|
||||
install_bundle(tmp_path, _plan(man_a), installer, manifest=man_a)
|
||||
install_bundle(tmp_path, _plan(man_b), installer, manifest=man_b)
|
||||
|
||||
# Removing B must NOT uninstall preset-a (still needed by A).
|
||||
result = remove_bundle(tmp_path, "b", installer)
|
||||
assert ("presets", "preset-a") in {(c.kind, c.id) for c in result.skipped}
|
||||
assert installer.is_installed(tmp_path, man_a.presets[0]) is True
|
||||
|
||||
remaining = {r.bundle_id for r in load_records(tmp_path)}
|
||||
assert remaining == {"a"}
|
||||
|
||||
|
||||
def test_remove_unknown_bundle_errors(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
with pytest.raises(BundlerError, match="not installed"):
|
||||
remove_bundle(tmp_path, "ghost", FakeInstaller())
|
||||
|
||||
|
||||
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
result = remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
# Removal flows populate the dedicated ``uninstalled`` list; ``installed``
|
||||
# stays empty so the result type is never ambiguous for callers.
|
||||
assert result.installed == []
|
||||
assert len(result.uninstalled) == 4
|
||||
assert installer.installed == set()
|
||||
|
||||
|
||||
def test_remove_counts_only_components_actually_removed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# Simulate one contributed component already gone from disk (e.g. removed
|
||||
# out of band). It must not be reported as uninstalled and remove() must
|
||||
# not be called for it.
|
||||
gone = manifest.components[0]
|
||||
installer.installed.discard((gone.kind, gone.id))
|
||||
|
||||
result = remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
assert len(result.uninstalled) == 3
|
||||
assert (gone.kind, gone.id) not in installer.remove_calls
|
||||
assert gone in result.skipped
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# With refresh, already-installed components are re-applied, not skipped.
|
||||
assert result.skipped == []
|
||||
assert len(result.refreshed) == 4
|
||||
assert len(installer.refresh_calls) == 4
|
||||
assert result.changed is True
|
||||
|
||||
|
||||
def test_refresh_falls_back_to_install_without_hook(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
|
||||
class NoRefreshInstaller(FakeInstaller):
|
||||
refresh = None # type: ignore[assignment]
|
||||
|
||||
installer = NoRefreshInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
before = len(installer.install_calls)
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# No refresh hook → re-install path keeps components current.
|
||||
assert len(result.refreshed) == 4
|
||||
assert len(installer.install_calls) == before + 4
|
||||
|
||||
|
||||
def test_update_preserves_original_installed_at(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
original = load_records(tmp_path)[0].installed_at
|
||||
|
||||
# A refresh (bundle update) must not rewrite the original install timestamp.
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)
|
||||
|
||||
assert load_records(tmp_path)[0].installed_at == original
|
||||
|
||||
|
||||
def test_refresh_does_not_touch_independently_installed_component(tmp_path: Path):
|
||||
# bundle update (refresh) must not re-apply a component installed
|
||||
# independently and tracked by no bundle — refreshing it would be a
|
||||
# collateral change to something the bundle does not own (FR-022).
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
installer.installed.add(("extensions", "ext-a"))
|
||||
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# ext-a is skipped (not refreshed) and never attributed to the bundle.
|
||||
assert ("extensions", "ext-a") not in installer.refresh_calls
|
||||
assert ("extensions", "ext-a") in {(c.kind, c.id) for c in result.skipped}
|
||||
assert ("extensions", "ext-a") not in {(c.kind, c.id) for c in result.refreshed}
|
||||
contributed = {
|
||||
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
|
||||
}
|
||||
assert ("extensions", "ext-a") not in contributed
|
||||
|
||||
|
||||
def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
|
||||
# A component installed independently (before any bundle) must not be
|
||||
# attributed to the bundle, so removing the bundle never uninstalls it
|
||||
# (FR-022, no collateral removal).
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
# Pre-install ext-a independently — no bundle record references it yet.
|
||||
installer.installed.add(("extensions", "ext-a"))
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
contributed = {
|
||||
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
|
||||
}
|
||||
assert ("extensions", "ext-a") not in contributed
|
||||
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
114
tests/integration/test_bundler_local_install.py
Normal file
114
tests/integration/test_bundler_local_install.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for installing a bundle from a local artifact/path (T045).
|
||||
|
||||
The resolution-level tests are pure; the end-to-end test installs the bundled
|
||||
``agent-context`` extension fully offline from a built ``.zip`` artifact,
|
||||
proving the real in-process primitive dispatch (T044) works without a network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.commands.bundle import _local_manifest_source
|
||||
from tests.bundler_helpers import make_project, valid_manifest_dict, write_manifest
|
||||
|
||||
|
||||
def test_local_source_none_for_non_path():
|
||||
assert _local_manifest_source("some-catalog-bundle-id") is None
|
||||
|
||||
|
||||
def test_local_source_from_directory(tmp_path: Path):
|
||||
write_manifest(tmp_path, valid_manifest_dict())
|
||||
manifest = _local_manifest_source(str(tmp_path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_from_bundle_yml(tmp_path: Path):
|
||||
path = write_manifest(tmp_path, valid_manifest_dict())
|
||||
manifest = _local_manifest_source(str(path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_from_zip_artifact(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
write_manifest(bundle_dir, valid_manifest_dict())
|
||||
(bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
|
||||
assert result.exit_code == 0, result.output
|
||||
artifact = next(bundle_dir.glob("*.zip"))
|
||||
|
||||
manifest = _local_manifest_source(str(artifact))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_rejects_unknown_file(tmp_path: Path):
|
||||
weird = tmp_path / "thing.txt"
|
||||
weird.write_text("nope", encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="not a recognised bundle source"):
|
||||
_local_manifest_source(str(weird))
|
||||
|
||||
|
||||
def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
|
||||
"""End-to-end: build → install (offline, local .zip) → list → remove."""
|
||||
project = make_project(tmp_path / "proj")
|
||||
|
||||
bundle_dir = tmp_path / "mini"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "mini",
|
||||
"name": "Mini",
|
||||
"version": "1.0.0",
|
||||
"role": "developer",
|
||||
"description": "minimal",
|
||||
"author": "tests",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"extensions": [{"id": "agent-context", "version": "1.0.0"}]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle_dir / "README.md").write_text("# Mini\n", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
previous = Path.cwd()
|
||||
os.chdir(project)
|
||||
try:
|
||||
build = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
|
||||
assert build.exit_code == 0, build.output
|
||||
artifact = next(bundle_dir.glob("*.zip"))
|
||||
|
||||
install = runner.invoke(app, ["bundle", "install", str(artifact), "--offline"])
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
assert ExtensionManager(project).registry.is_installed("agent-context")
|
||||
|
||||
listing = runner.invoke(app, ["bundle", "list"])
|
||||
assert "mini" in listing.output
|
||||
|
||||
remove = runner.invoke(app, ["bundle", "remove", "mini"])
|
||||
assert remove.exit_code == 0, remove.output
|
||||
assert not ExtensionManager(project).registry.is_installed("agent-context")
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
78
tests/integration/test_bundler_offline.py
Normal file
78
tests/integration/test_bundler_offline.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Offline-first tests (Constitution Principle IV).
|
||||
|
||||
Assert that consume/author flows work with no network access: built-in catalogs
|
||||
resolve offline, file:// catalogs resolve offline, and http(s) sources are
|
||||
refused (never silently attempted) when network is disabled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
|
||||
from specify_cli.bundler.services.adapters import make_catalog_fetcher
|
||||
from specify_cli.bundler.services.catalog_stack import CatalogStack
|
||||
from tests.bundler_helpers import catalog_entry_dict, write_catalog_file
|
||||
|
||||
|
||||
def _src(source_id, url, priority=1, policy="install-allowed"):
|
||||
return CatalogSource(
|
||||
id=source_id, url=url, priority=priority,
|
||||
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_catalog_resolves_offline():
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("default", "builtin://default")], fetcher)
|
||||
# Built-in default ships empty; search works without network and returns [].
|
||||
assert stack.search() == []
|
||||
|
||||
|
||||
def test_file_catalog_resolves_offline(tmp_path: Path):
|
||||
catalog_path = tmp_path / "catalog.json"
|
||||
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", str(catalog_path))], fetcher)
|
||||
resolved = stack.resolve("demo")
|
||||
assert resolved.entry.id == "demo"
|
||||
|
||||
|
||||
def test_http_source_refused_when_offline():
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("remote", "https://example.com/catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="Network access disabled"):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_missing_file_catalog_errors_offline(tmp_path: Path):
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", str(tmp_path / "nope.json"))], fetcher)
|
||||
with pytest.raises(BundlerError):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_file_url_catalog_resolves_offline(tmp_path: Path):
|
||||
catalog_path = tmp_path / "catalog.json"
|
||||
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", catalog_path.as_uri())], fetcher)
|
||||
resolved = stack.resolve("demo")
|
||||
assert resolved.entry.id == "demo"
|
||||
|
||||
|
||||
def test_plain_http_remote_rejected_before_network():
|
||||
# HTTPS is required for non-localhost catalogs; reject http:// up front.
|
||||
fetcher = make_catalog_fetcher(allow_network=True)
|
||||
stack = CatalogStack([_src("remote", "http://example.com/catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_remote_url_without_host_rejected():
|
||||
fetcher = make_catalog_fetcher(allow_network=True)
|
||||
stack = CatalogStack([_src("remote", "https:///catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="valid URL with a host"):
|
||||
stack.resolve("anything")
|
||||
173
tests/integration/test_bundler_security_paths.py
Normal file
173
tests/integration/test_bundler_security_paths.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Security tests: path-traversal / symlink confinement (Constitution Principle V).
|
||||
|
||||
These assert the bundler refuses to read or write outside an allowed root, so a
|
||||
malicious manifest or artifact path cannot escape the project/bundle directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath
|
||||
|
||||
|
||||
def test_ensure_within_allows_child(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
child = root / "sub" / "file.txt"
|
||||
assert ensure_within(root, child) == child.resolve()
|
||||
|
||||
|
||||
def test_ensure_within_rejects_parent_traversal(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
escape = root / ".." / "secret.txt"
|
||||
with pytest.raises(BundlerError, match="escapes"):
|
||||
ensure_within(root, escape)
|
||||
|
||||
|
||||
def test_ensure_within_rejects_absolute_outside(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
with pytest.raises(BundlerError):
|
||||
ensure_within(root, Path("/etc/passwd"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
|
||||
def test_ensure_within_rejects_symlink_escape(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
link = root / "link.txt"
|
||||
link.symlink_to(outside)
|
||||
with pytest.raises(BundlerError, match="escapes"):
|
||||
ensure_within(root, link)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel,safe", [
|
||||
("a/b.txt", True),
|
||||
("./a.txt", True),
|
||||
("../escape", False),
|
||||
("a/../../escape", False),
|
||||
("/abs", False),
|
||||
("C:/abs", False),
|
||||
("C:\\abs", False),
|
||||
("\\\\server\\share", False),
|
||||
("", False),
|
||||
])
|
||||
def test_is_safe_relpath(rel, safe):
|
||||
assert is_safe_relpath(rel) is safe
|
||||
|
||||
|
||||
def test_build_skips_symlinks(tmp_path: Path):
|
||||
"""Packager must not follow symlinks out of the bundle dir."""
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(bundle / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
|
||||
if os.name != "nt":
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("top secret", encoding="utf-8")
|
||||
(bundle / "leak.txt").symlink_to(secret)
|
||||
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert "leak.txt" not in names
|
||||
assert "bundle.yml" in names
|
||||
|
||||
|
||||
def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Reading bundle-records.json must honour the same confinement as writes:
|
||||
# a symlinked .specify pointing outside project_root is refused.
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-records.json").write_text(
|
||||
'{"schema_version": "1.0", "bundles": []}', encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
load_records(project)
|
||||
|
||||
|
||||
def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Reading the integration marker must not follow a .specify symlink that
|
||||
# resolves outside project_root; an escape is treated as "not determinable".
|
||||
from specify_cli.bundler.lib.project import active_integration
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "integration.json").write_text(
|
||||
'{"integration": "leaked"}', encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
assert active_integration(project) is None
|
||||
|
||||
|
||||
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
from specify_cli.bundler.commands_impl import catalog_config as cc
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-catalogs.yml").write_text(
|
||||
"schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path):
|
||||
from specify_cli.bundler.models.catalog import load_source_stack
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8")
|
||||
try:
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
load_source_stack(project)
|
||||
|
||||
|
||||
def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
|
||||
from specify_cli.bundler.lib.project import find_project_root
|
||||
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
try:
|
||||
(project / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
# A symlinked .specify must not be accepted as a project root.
|
||||
assert find_project_root(project) is None
|
||||
71
tests/unit/test_bundler_adapters.py
Normal file
71
tests/unit/test_bundler_adapters.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Unit tests for catalog-fetch adapters (auth + redirect safety)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy
|
||||
from specify_cli.bundler.services import adapters
|
||||
|
||||
|
||||
def _source(url: str) -> CatalogSource:
|
||||
return CatalogSource(
|
||||
id="team",
|
||||
url=url,
|
||||
priority=10,
|
||||
install_policy=InstallPolicy.INSTALL_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, body: bytes, final_url: str) -> None:
|
||||
self._body = body
|
||||
self._final_url = final_url
|
||||
|
||||
def __enter__(self) -> "_FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
return False
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._final_url
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
|
||||
def test_http_fetch_uses_shared_client_and_rejects_redirect_downgrade(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
|
||||
captured["url"] = url
|
||||
captured["validator"] = redirect_validator
|
||||
return _FakeResponse(b'{"schema_version": "1.0"}', url)
|
||||
|
||||
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
result = fetcher(_source("https://example.com/c.json"))
|
||||
assert result == {"schema_version": "1.0"}
|
||||
assert captured["url"] == "https://example.com/c.json"
|
||||
|
||||
# The validator handed to open_url must reject an HTTP downgrade redirect.
|
||||
validator = captured["validator"]
|
||||
assert validator is not None
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
validator("https://example.com/c.json", "http://evil.example/c.json")
|
||||
# And a same-scheme HTTPS redirect is allowed (no raise).
|
||||
validator("https://example.com/c.json", "https://cdn.example/c.json")
|
||||
|
||||
|
||||
def test_http_fetch_rejects_non_https_final_url(monkeypatch):
|
||||
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
|
||||
# Simulate a response whose final URL silently downgraded to HTTP.
|
||||
return _FakeResponse(b"{}", "http://evil.example/c.json")
|
||||
|
||||
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
fetcher(_source("https://example.com/c.json"))
|
||||
181
tests/unit/test_bundler_catalog_config.py
Normal file
181
tests/unit/test_bundler_catalog_config.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Unit tests for project catalog-config id derivation and url canonicalization."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.commands_impl import catalog_config as cc
|
||||
|
||||
|
||||
def test_derive_id_incorporates_path_stem_for_same_host():
|
||||
# Two catalogs on the same host must not collide on the derived id.
|
||||
a = cc._derive_id("https://example.com/team-a.json")
|
||||
b = cc._derive_id("https://example.com/team-b.json")
|
||||
assert a == "example-com-team-a"
|
||||
assert b == "example-com-team-b"
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_derive_id_distinguishes_tlds():
|
||||
# Different TLDs sharing a second-level label must not collide.
|
||||
com = cc._derive_id("https://example.com/team-a.json")
|
||||
net = cc._derive_id("https://example.net/team-a.json")
|
||||
assert com == "example-com-team-a"
|
||||
assert net == "example-net-team-a"
|
||||
assert com != net
|
||||
|
||||
|
||||
def test_derive_id_falls_back_to_host_when_no_path():
|
||||
assert cc._derive_id("https://example.com/") == "example-com"
|
||||
|
||||
|
||||
def test_derive_id_for_local_path_uses_stem():
|
||||
assert cc._derive_id("./catalogs/my-catalog.json") == "my-catalog"
|
||||
|
||||
|
||||
def test_canonicalize_makes_relative_local_path_absolute(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "local.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
result = cc._canonicalize_url("local.json")
|
||||
|
||||
assert Path(result).is_absolute()
|
||||
assert Path(result) == (tmp_path / "local.json").resolve()
|
||||
|
||||
|
||||
def test_canonicalize_leaves_remote_urls_untouched():
|
||||
for url in (
|
||||
"https://example.com/c.json",
|
||||
"http://localhost:8080/c.json",
|
||||
"file:///tmp/c.json",
|
||||
"builtin://default",
|
||||
):
|
||||
assert cc._canonicalize_url(url) == url
|
||||
|
||||
|
||||
def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
catalog = project / "sub" / "cat.json"
|
||||
catalog.parent.mkdir()
|
||||
catalog.write_text("{}", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project)
|
||||
source = cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)
|
||||
|
||||
assert Path(source.url).is_absolute()
|
||||
assert Path(source.url) == catalog.resolve()
|
||||
|
||||
|
||||
def test_add_source_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_read_rejects_non_list_catalogs(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.0'\ncatalogs: not-a-list\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="'catalogs' must be a list"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_non_mapping_catalog_entry(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.0'\ncatalogs:\n - just-a-string\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="each catalog entry must be a mapping"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_non_mapping_top_level(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text("- a\n- b\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_unknown_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '2.0'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="Unsupported catalog config schema version"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_accepts_forward_compatible_minor_schema(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.5'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_read_tolerates_missing_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text("catalogs: []\n", encoding="utf-8")
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_read_returns_empty_for_missing_or_empty_config(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
assert cc._read(project) == []
|
||||
|
||||
cc._config_path(project).write_text("schema_version: '1.0'\n", encoding="utf-8")
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_slug_lowercases_for_deterministic_ids():
|
||||
# Mixed-case local filenames must derive the same id regardless of case so
|
||||
# the case-sensitive duplicate check cannot admit logical duplicates.
|
||||
assert cc._slug("Team-A") == "team-a"
|
||||
assert cc._derive_id("./catalogs/Team-A.json") == "team-a"
|
||||
assert cc._derive_id("https://Example.com/Team-A.json") == "example-com-team-a"
|
||||
|
||||
|
||||
def test_derive_id_handles_ipv6_literal():
|
||||
# An IPv6 host must not be truncated at the first colon.
|
||||
derived = cc._derive_id("https://[2001:db8::1]/catalog.json")
|
||||
assert derived == "2001-db8--1-catalog"
|
||||
|
||||
|
||||
def test_derive_id_ignores_credentials_and_port():
|
||||
assert cc._derive_id("https://user:pw@example.com:8443/c.json") == "example-com-c"
|
||||
|
||||
|
||||
def test_add_source_rejects_unsupported_scheme(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Unsupported catalog url scheme"):
|
||||
cc.add_source(project, "ssh://host/catalog.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
monkeypatch.chdir(project)
|
||||
# A relative path containing ':' but no '://' is still a local path.
|
||||
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
|
||||
assert source.url.endswith("weird:name.json") or "weird" in source.url
|
||||
54
tests/unit/test_bundler_conflict.py
Normal file
54
tests/unit/test_bundler_conflict.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Unit tests for conflict detection (T034): integration clash and overlap precedence."""
|
||||
from __future__ import annotations
|
||||
|
||||
from specify_cli.bundler.models.manifest import BundleManifest, ComponentRef
|
||||
from specify_cli.bundler.models.records import InstalledBundleRecord
|
||||
from specify_cli.bundler.services.conflict import detect_conflicts
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_integration_clash_is_blocking():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is True
|
||||
assert "claude" in report.integration_clash
|
||||
assert "copilot" in report.integration_clash
|
||||
|
||||
|
||||
def test_matching_integration_no_clash():
|
||||
manifest = _manifest(integration={"id": "copilot"})
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_agnostic_bundle_never_clashes():
|
||||
manifest = _manifest() # no integration
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_overlap_with_other_bundle_is_reported():
|
||||
manifest = _manifest()
|
||||
other = InstalledBundleRecord.create(
|
||||
bundle_id="other",
|
||||
version="1.0.0",
|
||||
components=[ComponentRef(kind="presets", id="preset-a")],
|
||||
)
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[other])
|
||||
assert any("preset-a" in o and "other" in o for o in report.overlaps)
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_same_bundle_reinstall_is_not_overlap():
|
||||
manifest = _manifest()
|
||||
same = InstalledBundleRecord.create(
|
||||
bundle_id="demo-bundle",
|
||||
version="1.2.0",
|
||||
components=[ComponentRef(kind="presets", id="preset-a")],
|
||||
)
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[same])
|
||||
assert report.overlaps == []
|
||||
193
tests/unit/test_bundler_packager.py
Normal file
193
tests/unit/test_bundler_packager.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for the artifact packager (T023): contents, versioning, determinism."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(directory / "README.md").write_text("# Demo bundle", encoding="utf-8")
|
||||
for rel, content in (extra_files or {}).items():
|
||||
target = directory / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
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")
|
||||
assert result.artifact_path.name == "demo-bundle-1.2.0.zip"
|
||||
|
||||
|
||||
def test_artifact_contains_manifest_and_assets(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"assets/logo.txt": "logo"})
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "bundle.yml" in names
|
||||
assert "README.md" in names
|
||||
assert "assets/logo.txt" in names
|
||||
|
||||
|
||||
def test_build_refuses_invalid_manifest(tmp_path: Path):
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir()
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle / "README.md").write_text("# x", encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="validate"):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_build_missing_manifest_errors(tmp_path: Path):
|
||||
with pytest.raises(BundlerError, match="No bundle.yml"):
|
||||
build_bundle(tmp_path, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_build_is_deterministic(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a", "z.txt": "z"})
|
||||
first = build_bundle(bundle, output_dir=tmp_path / "out1")
|
||||
second = build_bundle(bundle, output_dir=tmp_path / "out2")
|
||||
with zipfile.ZipFile(first.artifact_path) as a, zipfile.ZipFile(second.artifact_path) as b:
|
||||
# Same files, same order (sorted).
|
||||
assert a.namelist() == b.namelist()
|
||||
# Fixed timestamps + permissions make each member byte-identical.
|
||||
for left, right in zip(a.infolist(), b.infolist()):
|
||||
assert left.date_time == right.date_time
|
||||
assert left.external_attr == right.external_attr
|
||||
# The whole artifact is byte-for-byte reproducible.
|
||||
assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes()
|
||||
|
||||
|
||||
def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
out_dir = bundle / "dist"
|
||||
# Build twice into a dir nested in the bundle; the second build must not
|
||||
# re-package the first artifact, so contents stay identical and bounded.
|
||||
first = build_bundle(bundle, output_dir=out_dir)
|
||||
second = build_bundle(bundle, output_dir=out_dir)
|
||||
with zipfile.ZipFile(second.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert not any(name.startswith("dist/") for name in names)
|
||||
assert not any(name.endswith(".zip") for name in names)
|
||||
assert first.file_count == second.file_count
|
||||
|
||||
|
||||
def test_prior_version_artifact_not_repackaged(tmp_path: Path):
|
||||
# An older artifact sitting next to bundle.yml must not be packaged.
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
(bundle / "demo-bundle-0.9.0.zip").write_bytes(b"PK\x03\x04 old artifact")
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert not any(name.endswith(".zip") for name in names)
|
||||
assert "demo-bundle-0.9.0.zip" not in names
|
||||
|
||||
|
||||
def test_symlinked_directory_is_not_followed(tmp_path: Path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
link = bundle / "linkdir"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
# Build must succeed (no ensure_within failure) and must not pull in the
|
||||
# out-of-tree file behind the symlinked directory.
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert "linkdir/secret.txt" not in names
|
||||
assert not any("secret" in name for name in names)
|
||||
|
||||
|
||||
def test_unsafe_bundle_id_is_rejected_before_build(tmp_path: Path):
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "../evil"
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir(parents=True)
|
||||
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle / "README.md").write_text("# x", encoding="utf-8")
|
||||
with pytest.raises(BundlerError):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
# The traversal target must not have been written outside out_dir.
|
||||
assert not (tmp_path / "evil-1.2.0.zip").exists()
|
||||
|
||||
|
||||
def test_build_refuses_missing_readme(tmp_path: Path):
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="README.md"):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_asset_zip_starting_with_bundle_id_is_packaged(tmp_path: Path):
|
||||
# A non-artifact asset whose name merely starts with the bundle id (but is
|
||||
# not a semver-named build artifact) must still be included.
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-assets.zip": "data"})
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-assets.zip" in names
|
||||
|
||||
|
||||
def test_prior_semver_artifact_is_excluded(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-0.9.0.zip": "old"})
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-0.9.0.zip" not in names
|
||||
|
||||
|
||||
def test_prior_artifact_with_prerelease_and_build_is_excluded(tmp_path: Path):
|
||||
# A semver artifact carrying both prerelease and build metadata must still
|
||||
# be recognized as a prior build artifact and excluded.
|
||||
bundle = _make_bundle(
|
||||
tmp_path / "b", extra_files={"demo-bundle-1.0.0-rc1+build5.zip": "old"}
|
||||
)
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-1.0.0-rc1+build5.zip" not in names
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="Windows filesystems do not carry Unix execute bits, so chmod(0o755) "
|
||||
"is a no-op and there is no executability to preserve.",
|
||||
)
|
||||
def test_executable_bit_preserved_in_artifact(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "bundle")
|
||||
script = bundle / "scripts" / "hook.sh"
|
||||
script.parent.mkdir(parents=True, exist_ok=True)
|
||||
script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
||||
script.chmod(0o755)
|
||||
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
modes = {
|
||||
info.filename: (info.external_attr >> 16) & 0o777
|
||||
for info in archive.infolist()
|
||||
}
|
||||
# Executable source -> 0755; plain text files -> 0644.
|
||||
assert modes["scripts/hook.sh"] == 0o755
|
||||
assert modes["README.md"] == 0o644
|
||||
133
tests/unit/test_bundler_primitives.py
Normal file
133
tests/unit/test_bundler_primitives.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for the primitive-dispatch bridge (T044).
|
||||
|
||||
Covers routing, offline gating, and the network-aware ``DefaultPrimitiveInstaller``
|
||||
seam — without touching real catalogs or the network (Constitution Principle II,
|
||||
offline-first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from specify_cli.bundler.services.primitives import (
|
||||
_ExtensionKindManager,
|
||||
_PresetKindManager,
|
||||
_StepKindManager,
|
||||
_WorkflowKindManager,
|
||||
primitive_manager,
|
||||
)
|
||||
|
||||
|
||||
def _component(kind: str, cid: str = "x") -> ComponentRef:
|
||||
return ComponentRef(kind=kind, id=cid)
|
||||
|
||||
|
||||
def test_primitive_manager_routes_each_kind(tmp_path: Path):
|
||||
assert isinstance(primitive_manager("presets", tmp_path), _PresetKindManager)
|
||||
assert isinstance(primitive_manager("extensions", tmp_path), _ExtensionKindManager)
|
||||
assert isinstance(primitive_manager("workflows", tmp_path), _WorkflowKindManager)
|
||||
assert isinstance(primitive_manager("steps", tmp_path), _StepKindManager)
|
||||
|
||||
|
||||
def test_primitive_manager_rejects_unknown_kind(tmp_path: Path):
|
||||
with pytest.raises(BundlerError, match="Unknown component kind"):
|
||||
primitive_manager("bogus", tmp_path)
|
||||
|
||||
|
||||
def test_offline_preset_not_bundled_refuses(tmp_path: Path):
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("presets", "definitely-not-bundled"))
|
||||
|
||||
|
||||
def test_offline_extension_not_bundled_refuses(tmp_path: Path):
|
||||
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("extensions", "definitely-not-bundled"))
|
||||
|
||||
|
||||
def test_offline_workflow_refuses_without_network(tmp_path: Path):
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("workflows"))
|
||||
|
||||
|
||||
def test_offline_step_refuses_without_network(tmp_path: Path):
|
||||
manager = primitive_manager("steps", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("steps"))
|
||||
|
||||
|
||||
def test_default_installer_threads_allow_network(tmp_path: Path):
|
||||
installer = DefaultPrimitiveInstaller(allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
installer.install(tmp_path, _component("workflows"))
|
||||
|
||||
|
||||
def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch):
|
||||
# A workflow that ships with Spec Kit must install even with --offline.
|
||||
import specify_cli
|
||||
import specify_cli._assets as assets
|
||||
|
||||
monkeypatch.setattr(
|
||||
assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf"
|
||||
)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(specify_cli, "workflow_add", lambda wid: calls.append(wid))
|
||||
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
||||
manager.install(_component("workflows", "bundled-wf"))
|
||||
|
||||
assert calls == ["bundled-wf"]
|
||||
|
||||
|
||||
def test_assert_pinned_version_matches_passes():
|
||||
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
||||
|
||||
# Equal (including v-prefix/normalization) is accepted; no version pins are no-ops.
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", "2.0.0")
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", "v2.0.0")
|
||||
_assert_pinned_version("Preset", "p", None, "9.9.9")
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", None)
|
||||
|
||||
|
||||
def test_assert_pinned_version_mismatch_raises():
|
||||
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
||||
|
||||
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
||||
_assert_pinned_version("Preset", "preset-a", "2.0.0", "3.1.0")
|
||||
|
||||
|
||||
def test_workflow_version_mismatch_refuses(tmp_path: Path, monkeypatch):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
monkeypatch.setattr(
|
||||
WorkflowCatalog, "get_workflow_info", lambda self, wid: {"version": "9.9.9"}
|
||||
)
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=True)
|
||||
component = ComponentRef(kind="workflows", id="wf-a", version="0.3.0")
|
||||
with pytest.raises(BundlerError, match="pinned to version 0.3.0"):
|
||||
manager.install(component)
|
||||
|
||||
|
||||
def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeypatch):
|
||||
import specify_cli._assets as assets
|
||||
|
||||
calls = {}
|
||||
|
||||
class _FakeManager:
|
||||
def install_from_directory(self, directory, speckit_version, priority):
|
||||
calls["priority"] = priority
|
||||
|
||||
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: tmp_path)
|
||||
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
manager._manager = _FakeManager()
|
||||
manager.install(ComponentRef(kind="presets", id="p", priority=0))
|
||||
|
||||
# An explicit priority of 0 must be passed through, not replaced by default.
|
||||
assert calls["priority"] == 0
|
||||
190
tests/unit/test_bundler_records.py
Normal file
190
tests/unit/test_bundler_records.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Unit tests for installed-bundle records and collateral-protection logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.models.records import (
|
||||
InstalledBundleRecord,
|
||||
components_still_needed,
|
||||
load_records,
|
||||
records_path,
|
||||
remove_record,
|
||||
save_records,
|
||||
upsert_record,
|
||||
)
|
||||
|
||||
|
||||
def _record(bundle_id: str, comps) -> InstalledBundleRecord:
|
||||
return InstalledBundleRecord.create(
|
||||
bundle_id=bundle_id,
|
||||
version="1.0.0",
|
||||
components=[ComponentRef(kind=k, id=i) for k, i in comps],
|
||||
)
|
||||
|
||||
|
||||
def test_save_and_load_roundtrip(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
rec = _record("a", [("presets", "p1"), ("steps", "s1")])
|
||||
save_records(tmp_path, [rec])
|
||||
loaded = load_records(tmp_path)
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].bundle_id == "a"
|
||||
assert {(c.kind, c.id) for c in loaded[0].contributed_components} == {
|
||||
("presets", "p1"),
|
||||
("steps", "s1"),
|
||||
}
|
||||
|
||||
|
||||
def test_load_missing_file_returns_empty(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
def test_corrupt_priority_raises_actionable_error(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
rec = _record("a", [("presets", "p1")])
|
||||
save_records(tmp_path, [rec])
|
||||
path = records_path(tmp_path)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["bundles"][0]["contributed_components"][0]["priority"] = "high"
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="priority must be an integer"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_upsert_replaces_same_id():
|
||||
rec1 = _record("a", [("presets", "p1")])
|
||||
rec2 = _record("a", [("presets", "p2")])
|
||||
result = upsert_record([rec1], rec2)
|
||||
assert len(result) == 1
|
||||
assert result[0].contributed_components[0].id == "p2"
|
||||
|
||||
|
||||
def test_remove_record_drops_target():
|
||||
recs = [_record("a", [("presets", "p1")]), _record("b", [("steps", "s1")])]
|
||||
result = remove_record(recs, "a")
|
||||
assert [r.bundle_id for r in result] == ["b"]
|
||||
|
||||
|
||||
def test_components_still_needed_excludes_target():
|
||||
recs = [
|
||||
_record("a", [("presets", "shared"), ("steps", "only-a")]),
|
||||
_record("b", [("presets", "shared")]),
|
||||
]
|
||||
needed = components_still_needed(recs, exclude_bundle_id="a")
|
||||
assert ("presets", "shared") in needed
|
||||
assert ("steps", "only-a") not in needed
|
||||
|
||||
|
||||
def test_save_records_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Defense-in-depth: a symlinked .specify pointing outside the project must
|
||||
# not let records be written outside project_root.
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
save_records(project, [_record("a", [("presets", "p1")])])
|
||||
|
||||
|
||||
def test_load_records_rejects_non_list_bundles(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
path.write_text(json.dumps({"schema_version": "1.0", "bundles": "oops"}), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="'bundles' must be a list"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_non_list_contributed_components(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"bundles": [
|
||||
{"bundle_id": "a", "version": "1.0.0", "contributed_components": "oops"}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="'contributed_components' must be a list"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_unknown_component_kind(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"bundles": [
|
||||
{
|
||||
"bundle_id": "a",
|
||||
"version": "1.0.0",
|
||||
"contributed_components": [{"kind": "bogus", "id": "x"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="must be one of"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_component_missing_id(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"bundles": [
|
||||
{
|
||||
"bundle_id": "a",
|
||||
"version": "1.0.0",
|
||||
"contributed_components": [{"kind": "presets", "id": ""}],
|
||||
}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing its 'id'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_missing_schema_version(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
records_path(tmp_path).write_text(json.dumps({"bundles": []}), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing 'schema_version'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_unknown_schema_version(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "2.0", "bundles": []}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="Unsupported records schema version"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_record_missing_bundle_id(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "1.0", "bundles": [{"version": "1.0.0"}]}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing its 'bundle_id'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_record_missing_version(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "1.0", "bundles": [{"bundle_id": "a"}]}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing its 'version'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_accepts_forward_compatible_minor_schema(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "1.5", "bundles": []}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
assert load_records(tmp_path) == []
|
||||
41
tests/unit/test_bundler_references.py
Normal file
41
tests/unit/test_bundler_references.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Unit tests for the bundle reference checker (T047 / FR-005 / SC-007).
|
||||
|
||||
Resolution is offline-first: bundled and installed components resolve without a
|
||||
network; unknown ids fail online and downgrade to warnings offline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.services.references import make_reference_checker
|
||||
from tests.bundler_helpers import make_project
|
||||
|
||||
|
||||
def _ref(kind: str, id_: str) -> ComponentRef:
|
||||
return ComponentRef(kind=kind, id=id_, version="1.0.0")
|
||||
|
||||
|
||||
def test_bundled_extension_resolves(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=True, warnings=warnings)
|
||||
assert check(_ref("extensions", "agent-context")) is None
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_unknown_reference_errors_online(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=True, warnings=warnings)
|
||||
problem = check(_ref("presets", "does-not-exist"))
|
||||
assert problem is not None
|
||||
assert "does-not-exist" in problem
|
||||
|
||||
|
||||
def test_unknown_reference_warns_offline(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=False, warnings=warnings)
|
||||
assert check(_ref("presets", "does-not-exist")) is None
|
||||
assert any("does-not-exist" in w for w in warnings)
|
||||
81
tests/unit/test_bundler_resolver.py
Normal file
81
tests/unit/test_bundler_resolver.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for the resolver: version gate and integration compatibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_plan_expands_all_components():
|
||||
plan = resolve_install_plan(
|
||||
_manifest(), speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.component_count == 4
|
||||
assert plan.bundle_id == "demo-bundle"
|
||||
|
||||
|
||||
def test_version_gate_refuses_incompatible():
|
||||
manifest = _manifest(requires={"speckit_version": ">=99.0.0"})
|
||||
with pytest.raises(BundlerError, match="requires Spec Kit"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_integration_clash_halts():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
with pytest.raises(BundlerError, match="active integration"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_agnostic_inherits_active_integration():
|
||||
plan = resolve_install_plan(
|
||||
_manifest(), speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.effective_integration == "copilot"
|
||||
|
||||
|
||||
def test_matching_integration_is_allowed():
|
||||
manifest = _manifest(integration={"id": "copilot"})
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.effective_integration == "copilot"
|
||||
|
||||
|
||||
def test_pinned_integration_with_indeterminate_active_fails():
|
||||
# FR-019 guard: a bundle that pins an integration must not silently adopt it
|
||||
# when the project's active integration cannot be determined.
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
with pytest.raises(BundlerError, match="could not be determined"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration=None
|
||||
)
|
||||
|
||||
|
||||
def test_pinned_integration_with_indeterminate_active_allows_explicit_override():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
plan = resolve_install_plan(
|
||||
manifest,
|
||||
speckit_version="0.11.2",
|
||||
active_integration="claude",
|
||||
integration_explicit=True,
|
||||
)
|
||||
assert plan.effective_integration == "claude"
|
||||
|
||||
|
||||
def test_tool_requirements_become_warnings():
|
||||
manifest = _manifest(requires={"speckit_version": ">=0.1.0", "tools": ["docker"]})
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert any("docker" in w for w in plan.warnings)
|
||||
32
tests/unit/test_bundler_validator.py
Normal file
32
tests/unit/test_bundler_validator.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Unit tests for the bundle manifest validator service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.services import validator as validator_mod
|
||||
from specify_cli.bundler.services.validator import validate_manifest
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_invalid_speckit_constraint_reported_as_error():
|
||||
manifest = _manifest(requires={"speckit_version": ">>bad"})
|
||||
report = validate_manifest(manifest)
|
||||
assert not report.ok
|
||||
assert any("speckit_version" in e for e 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.
|
||||
def boom(_value):
|
||||
raise RuntimeError("unexpected bug")
|
||||
|
||||
monkeypatch.setattr(validator_mod, "parse_constraint", boom)
|
||||
manifest = _manifest(requires={"speckit_version": ">=1.0.0"})
|
||||
with pytest.raises(RuntimeError, match="unexpected bug"):
|
||||
validate_manifest(manifest)
|
||||
68
tests/unit/test_bundler_versioning.py
Normal file
68
tests/unit/test_bundler_versioning.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for version parsing and constraint satisfaction (FR-016 gate)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.lib.versioning import is_semver, satisfies
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("1.0.0", True),
|
||||
("0.11.2", True),
|
||||
("1.2.3-rc1", True),
|
||||
("1.2.3-alpha1", True),
|
||||
("1.2.3-beta2", True),
|
||||
("v1.2.3", True),
|
||||
("not-a-version", False),
|
||||
("", False),
|
||||
# packaging.version.Version accepts these partial versions; SemVer must not.
|
||||
("1", False),
|
||||
("1.0", False),
|
||||
("1.2.3.4", False),
|
||||
])
|
||||
def test_is_semver(value, expected):
|
||||
assert is_semver(value) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installed,constraint,ok", [
|
||||
("0.11.2", ">=0.1.0", True),
|
||||
("0.11.2", ">=1.0.0", False),
|
||||
("1.0.0", ">=1.0.0,<2.0.0", True),
|
||||
("2.0.0", ">=1.0.0,<2.0.0", False),
|
||||
("1.5.0", "", True), # empty constraint is permissive
|
||||
# Prerelease spellings normalize consistently for constraint checks.
|
||||
("1.2.3-rc1", ">=1.2.0", True),
|
||||
("1.2.3-alpha1", ">=2.0.0", False),
|
||||
])
|
||||
def test_satisfies(installed, constraint, ok):
|
||||
assert satisfies(installed, constraint) is ok
|
||||
|
||||
|
||||
def test_invalid_constraint_raises():
|
||||
with pytest.raises(BundlerError):
|
||||
satisfies("1.0.0", ">>bad")
|
||||
|
||||
|
||||
def test_uppercase_v_prefix_tolerated():
|
||||
# Mirrors specify_cli._version tag normalization (V -> v).
|
||||
assert is_semver("V1.2.3") is True
|
||||
assert satisfies("V1.2.3", ">=1.2.0") is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installed,constraint,ok", [
|
||||
# Prerelease spellings are now normalized inside constraints too, so a
|
||||
# constraint like ">=1.2.3-rc1" parses (previously raised InvalidSpecifier).
|
||||
("1.2.3-rc2", ">=1.2.3-rc1", True),
|
||||
("1.2.2", ">=1.2.3-rc1", False),
|
||||
("1.5.0", ">=1.2.3-rc1,<2.0.0", True),
|
||||
("1.2.3-beta.1", ">=1.2.3-alpha1", True),
|
||||
])
|
||||
def test_satisfies_prerelease_in_constraint(installed, constraint, ok):
|
||||
assert satisfies(installed, constraint) is ok
|
||||
|
||||
|
||||
def test_parse_constraint_empty_is_permissive():
|
||||
from specify_cli.bundler.lib.versioning import parse_constraint
|
||||
|
||||
assert str(parse_constraint("")) == ""
|
||||
Reference in New Issue
Block a user