Stage 1: Integration foundation — base classes, manifest system, and registry (#1925)

* feat: Stage 1 — integration foundation (base classes, manifest, registry)

Add the integrations package with:
- IntegrationBase ABC and MarkdownIntegration base class
- IntegrationOption dataclass for per-integration CLI options
- IntegrationManifest with SHA-256 hash-tracked install/uninstall
- INTEGRATION_REGISTRY (empty, populated in later stages)
- 34 tests at 98% coverage

Purely additive — no existing code modified.

Part of #1924

* fix: normalize manifest keys to POSIX, type manifest parameter

- Store manifest file keys using as_posix() after resolving relative
  to project root, ensuring cross-platform portable manifests
- Type the manifest parameter as IntegrationManifest (via TYPE_CHECKING
  import) instead of Any in IntegrationBase methods

* fix: symlink safety in uninstall/setup, handle invalid JSON in load

- uninstall() now uses non-resolved path for deletion so symlinks
  themselves are removed, not their targets; resolve only for
  containment validation
- setup() keeps unresolved dst_file for copy; resolves separately
  for project-root validation
- load() catches json.JSONDecodeError and re-raises as ValueError
  with the manifest path for clearer diagnostics
- Added test for invalid JSON manifest loading

* fix: lexical symlink containment, assert project_root consistency

- uninstall() now uses os.path.normpath for lexical containment check
  instead of resolve(), so in-project symlinks pointing outside are
  still properly removed
- setup() asserts manifest.project_root matches the passed project_root
  to prevent path mismatches between file operations and manifest
  recording

* fix: handle non-files in check_modified/uninstall, validate manifest key

- check_modified() treats non-regular-files (dirs, symlinks) as modified
  instead of crashing with IsADirectoryError
- uninstall() skips directories (adds to skipped list), only unlinks
  files and symlinks
- load() validates stored integration key matches the requested key

* fix: safe symlink handling in uninstall

- Broken symlinks now removable (lexists check via is_symlink fallback)
- Symlinks never hashed (avoids following to external targets)
- Symlinks only removed with force=True, otherwise skipped

* fix: robust unlink, fail-fast config validation, symlink tests

- uninstall() wraps path.unlink() in try/except OSError to avoid
  partial cleanup on race conditions or permission errors
- setup() raises ValueError on missing config or folder instead of
  silently returning empty
- Added 3 tests: symlink in check_modified, symlink skip/force in
  uninstall (47 total)

* fix: check_modified uses lexical containment, explicit is_symlink check

- check_modified() no longer calls _validate_rel_path (which resolves
  symlinks); uses lexical checks (is_absolute, '..' in parts) instead
- is_symlink() checked before is_file() so symlinks to files are still
  treated as modified
- Fixed templates_dir() docstring to match actual behavior

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
This commit is contained in:
Copilot
2026-03-31 10:37:00 -05:00
committed by GitHub
parent 4dff63a84e
commit 804cd10c71
4 changed files with 974 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
"""Integration registry for AI coding assistants.
Each integration is a self-contained subpackage that handles setup/teardown
for a specific AI assistant (Copilot, Claude, Gemini, etc.).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .base import IntegrationBase
# Maps integration key → IntegrationBase instance.
# Populated by later stages as integrations are migrated.
INTEGRATION_REGISTRY: dict[str, IntegrationBase] = {}
def _register(integration: IntegrationBase) -> None:
"""Register an integration instance in the global registry.
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
"""
key = integration.key
if not key:
raise ValueError("Cannot register integration with an empty key.")
if key in INTEGRATION_REGISTRY:
raise KeyError(f"Integration with key {key!r} is already registered.")
INTEGRATION_REGISTRY[key] = integration
def get_integration(key: str) -> IntegrationBase | None:
"""Return the integration for *key*, or ``None`` if not registered."""
return INTEGRATION_REGISTRY.get(key)