From 6fb7e77b3e7e12fb4062060f0e47a9ba1a2d9604 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 30 Jun 2026 21:41:57 +0700 Subject: [PATCH] fix: allow prerelease spec-kit versions in compatibility checks (#2695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: generate integrations reference from catalog * refactor: integrate table rendering into specify integration search --markdown - Remove standalone scripts/generate_integrations_reference.py - Strip doc injection machinery from catalog_docs.py; keep only table rendering - Wire render_integrations_table() into existing --markdown flag of integration search - Remove old simple markdown table block from integration_search (was Name|ID|Version|Description|Author) - Simplify tests: drop subprocess/doc-path tests, keep table rendering and metadata tests - Clean up docs/reference/integrations.md: remove generated markers, update note * fix: address Copilot review feedback on catalog_docs and integration_search - Warn when --markdown is combined with filters (query/--tag/--author) which are silently ignored; catch ValueError/FileNotFoundError and surface clean error via console instead of raw traceback (r3244821516) - Add coverage enforcement in list_integrations_for_docs(): raises ValueError with actionable message if any registry key is missing from INTEGRATION_DOC_URLS, preventing silently incomplete doc tables (r3244821589) - Rename test to accurately reflect sources: label derives from registry config, URL comes from INTEGRATION_DOC_URLS doc map — not solely from registry (r3244821607) - Simplify test dict construction to idiomatic dict comprehension (r3244821619) * fix: add sync test, INTEGRATIONS_REFERENCE_PATH constant, and fix naming * revert: restore docs/reference/integrations.md to upstream/main; remove sync test (GH Actions job will handle) * fix: remove dead INTEGRATIONS_REFERENCE_PATH, drop URL-length padding, fix docstring, drop FileNotFoundError * fix: send --markdown warnings/errors to stderr, rename test for clarity * fix: detect stale doc-map keys, test _render_cell escaping, strengthen header assertion * refactor: promote _render_cell to public render_cell function * test: mock registry and doc maps to avoid brittle live registry coupling * refactor: flatten patches, remove unused imports, fix trailing whitespace, optimize missing calculation * refactor: make validation non-fatal, fix context manager syntax, add CLI tests * fix: improve docstring clarity, test robustness, and exception handling * fix: improve test assertions, disable warnings by default, enhance exception handling * fix: make CLI tests deterministic and improve config access resilience * fix: remove extra blank line, add stale keys validation, add regression test for docs sync * Fix 5 remaining feedback items: - Rename _get_mocked_cli_runner() to _get_catalog_docs_patches() for clarity - Use ExitStack context manager for guaranteed patch cleanup - Add explicit UTF-8 encoding to file reads - Skip doc sync test gracefully when docs aren't present - Remove exception chaining from typer.Exit to avoid noisy tracebacks * address all outstanding copilot review feedback on PR 2563 * Address Copilot feedback: escape URLs in markdown links, deduplicate cell rendering, fix table parser for escaped pipes * Address 3 new Copilot feedback: add URL escaping test, fix parse_first_markdown_table for escaped pipes, guard community tests with skip * Address 3 new Copilot feedback: escape id field, remove unused alias, escape integration URLs * Address 3 new Copilot feedback: fix comment name, include all integrations in list * Fix architectural issue: escape raw fields before composing Markdown to prevent double-escaping * Deduplicate _escape_url_for_markdown_link and add URL escaping test * Address 4 new Copilot feedback: add trailing newline, fix test helper ExitStack, update warning message * Address 4 new Copilot feedback: make escape function public, fix error message, validate test rows, prevent double newline * Update error message in test_missing_catalog_file for clarity * Remove obsolete integrations sync test * keep integrations docs in sync * fix: allow prerelease spec-kit versions in compatibility checks Allow prerelease/dev builds to satisfy extension and preset compatibility checks when their version number falls within the required specifier range. Also harden the integrations docs rendering helpers and add regression coverage for the markdown table parsing and version gating paths. Tests: pytest -q; python3 -m compileall -q .; black/flake8 unavailable Reference: branch 002-generate-integrations-docs; source patch /tmp/spec-kit-changes.patch * fix: isolate prerelease compatibility gate changes Keep the prerelease/version compatibility fix on its own branch and remove the unrelated integrations docs updates that belong with PR 2563. Tests: full suite passed on the prerelease branch before splitting; docs branch covered by targeted docs tests Reference: upstream/main; source patch /tmp/spec-kit-changes.patch * Address PR 2695 feedback: Centralize prerelease policy and add boundary test * Address remaining Copilot PR feedback: revert docs and add preset prerelease tests * Remove unreachable raise CompatibilityError * Fix PEP8 E302 and E303 formatting issues --- src/specify_cli/__init__.py | 1 - src/specify_cli/_utils.py | 24 +++++++++++++++++ src/specify_cli/extensions/__init__.py | 36 +++++++------------------- src/specify_cli/presets/__init__.py | 19 ++++++-------- tests/test_extensions.py | 16 +++++++++++- tests/test_presets.py | 9 +++++++ 6 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5d5361cc8..450cf6cb2 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -528,7 +528,6 @@ def _require_specify_project() -> Path: raise typer.Exit(1) - # ===== Preset Commands ===== # Moved to presets/_commands.py — registered here to preserve CLI surface. diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index df0b8ddec..6603d65c4 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -304,3 +304,27 @@ def _display_project_path(project_root: Path, path: str | Path) -> str: except (OSError, ValueError): return path_obj.as_posix() return rel_path.as_posix() + + +def version_satisfies(current: str, required: str) -> bool: + """Check if current version satisfies required version specifier. + + Evaluates the version against the specifier using the project's + prerelease policy (prereleases are allowed). + + Args: + current: Current version (e.g., "0.1.5") + required: Required version specifier (e.g., ">=0.1.0,<2.0.0") + + Returns: + True if version satisfies requirement + """ + from packaging import version as pkg_version + from packaging.specifiers import InvalidSpecifier, SpecifierSet + + try: + current_ver = pkg_version.Version(current) + specifier = SpecifierSet(required) + return specifier.contains(current_ver, prereleases=True) + except (pkg_version.InvalidVersion, InvalidSpecifier): + return False diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 9271a9fde..f219c7e8d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -28,7 +28,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from .._init_options import is_ai_skills_enabled from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent -from .._utils import dump_frontmatter, relative_extension_path_violation +from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies from ..catalogs import CatalogEntry as BaseCatalogEntry from ..catalogs import CatalogStackBase from ..shared_infra import verify_archive_sha256 @@ -1279,20 +1279,20 @@ class ExtensionManager: CompatibilityError: If extension is incompatible """ required = manifest.requires_speckit_version - current = pkg_version.Version(speckit_version) # Parse version specifier (e.g., ">=0.1.0,<2.0.0") try: - specifier = SpecifierSet(required) - if current not in specifier: - raise CompatibilityError( - f"Extension requires spec-kit {required}, " - f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: {REINSTALL_COMMAND}" - ) + SpecifierSet(required) # Just to validate except InvalidSpecifier: raise CompatibilityError(f"Invalid version specifier: {required}") + if not version_satisfies(speckit_version, required): + raise CompatibilityError( + f"Extension requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" + ) + return True def install_from_directory( @@ -1871,24 +1871,6 @@ class ExtensionManager: return None -def version_satisfies(current: str, required: str) -> bool: - """Check if current version satisfies required version specifier. - - Args: - current: Current version (e.g., "0.1.5") - required: Required version specifier (e.g., ">=0.1.0,<2.0.0") - - Returns: - True if version satisfies requirement - """ - try: - current_ver = pkg_version.Version(current) - specifier = SpecifierSet(required) - return current_ver in specifier - except (pkg_version.InvalidVersion, InvalidSpecifier): - return False - - class CommandRegistrar: """Handles registration of extension commands with AI agents. diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index cf89b788a..863b6ef7d 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -30,7 +30,7 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority from .._init_options import is_ai_skills_enabled from ..integrations.base import IntegrationBase -from .._utils import dump_frontmatter +from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import verify_archive_sha256 @@ -572,19 +572,16 @@ class PresetManager: PresetCompatibilityError: If pack is incompatible """ required = manifest.requires_speckit_version - current = pkg_version.Version(speckit_version) - try: - specifier = SpecifierSet(required) - if current not in specifier: - raise PresetCompatibilityError( - f"Preset requires spec-kit {required}, " - f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: {REINSTALL_COMMAND}" - ) + SpecifierSet(required) # Just to validate except InvalidSpecifier: + raise PresetCompatibilityError(f"Invalid version specifier: {required}") + + if not version_satisfies(speckit_version, required): raise PresetCompatibilityError( - f"Invalid version specifier: {required}" + f"Preset requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" ) return True diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6260ad6ab..4a07a2105 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -37,8 +37,8 @@ from specify_cli.extensions import ( ValidationError, CompatibilityError, normalize_priority, - version_satisfies, ) +from specify_cli._utils import version_satisfies # Minimal valid ZIP (empty end-of-central-directory record). Passes # zipfile.is_zipfile() so --from download tests exercise the content guard. @@ -1005,6 +1005,14 @@ class TestExtensionManager: with pytest.raises(CompatibilityError, match="Extension requires spec-kit"): manager.check_compatibility(manifest, "0.0.1") + def test_check_compatibility_allows_prerelease_builds(self, extension_dir, project_dir): + """Prerelease spec-kit builds should satisfy compatible version ranges.""" + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + result = manager.check_compatibility(manifest, "0.8.8.dev0") + assert result is True + def test_install_from_directory(self, extension_dir, project_dir): """Test installing extension from directory.""" manager = ExtensionManager(project_dir) @@ -2629,6 +2637,12 @@ class TestVersionSatisfies: assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3") assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3") + def test_version_satisfies_prerelease(self): + """Prerelease builds should satisfy compatible lower bounds, but not higher bounds.""" + assert version_satisfies("0.8.8.dev0", ">=0.2.0") + assert not version_satisfies("0.2.0.dev0", ">=0.2.0") + assert not version_satisfies("0.8.7.dev1", ">=0.8.8") + def test_version_satisfies_invalid(self): """Test invalid version strings.""" assert not version_satisfies("invalid", ">=1.0.0") diff --git a/tests/test_presets.py b/tests/test_presets.py index ff3f3dffc..054018b7a 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -710,6 +710,15 @@ class TestPresetManager: manifest = PresetManifest(pack_dir / "preset.yml") assert manager.check_compatibility(manifest, "0.1.5") is True + def test_check_compatibility_prerelease(self, pack_dir, temp_dir): + """Test compatibility check allows prereleases and fails on boundary.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + # manifest requires >=0.1.0 + assert manager.check_compatibility(manifest, "0.8.8.dev0") is True + with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"): + manager.check_compatibility(manifest, "0.1.0.dev0") + def test_check_compatibility_invalid(self, pack_dir, temp_dir): """Test compatibility check with invalid specifier.""" manager = PresetManager(temp_dir)