Files
markitect-main/tests/test_llm_isolation.py
tegwick 36c20f37d0
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
feat(llm): extract adapter layer for standalone llm-connect package (S1+S2)
Stage 1 — Decouple:
- Move RunConfig + LLMResponse to markitect/llm/models.py (canonical)
- Move LLMAdapter + Mock/ErrorLLMAdapter to markitect/llm/adapter.py
- markitect/prompts/execution/models.py and llm_adapter.py become re-export shims
- All 4 adapters + factory.py updated to import from markitect.llm.*
- Parameterize app_name in toml_config.py (resolve_llm, get_default_layers,
  get_preference_layers): paths and env var now derived from app_name arg
- Add tests/test_llm_isolation.py: 7 isolation + backward-compat tests

Stage 2 — Extract:
- Standalone llm-connect package created at ~/llm-connect/
- All 18 llm files copied; markitect.* imports replaced with llm_connect.*
- LLMError base inlined in llm_connect/exceptions.py (no markitect dep)
- llm-connect installed into markitect-venv; declared in pyproject.toml

Smoke test: markitect llm-check succeeds (live Gemini API call).
Backward compat: markitect.prompts.execution.{models,llm_adapter} still work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 08:04:50 +01:00

160 lines
5.9 KiB
Python

"""
S1.3 — LLM isolation gate.
Confirms that markitect.llm.* has zero imports from markitect.prompts.*
or markitect.infospace.*, making the module safe to extract into a
standalone llm-connect library.
These tests must pass before extraction (S2).
"""
import importlib
import pkgutil
import sys
from pathlib import Path
def _collect_llm_modules() -> list[str]:
"""Return fully-qualified names of all modules under markitect.llm."""
import markitect.llm as pkg
pkg_path = Path(pkg.__file__).parent
names = []
for info in pkgutil.walk_packages([str(pkg_path)], prefix="markitect.llm."):
names.append(info.name)
# Include the package itself
names.insert(0, "markitect.llm")
return names
def _direct_imports(module_name: str) -> set[str]:
"""Return set of top-level module names imported by *module_name*."""
mod = importlib.import_module(module_name)
src_file = getattr(mod, "__file__", None)
if not src_file or not src_file.endswith(".py"):
return set()
imports: set[str] = set()
with open(src_file) as f:
for line in f:
stripped = line.strip()
if stripped.startswith("from ") or stripped.startswith("import "):
# Extract the root package of the imported name
parts = stripped.split()
if parts[0] == "from" and len(parts) >= 2:
imports.add(parts[1].split(".")[0] + "." + parts[1].split(".")[1]
if "." in parts[1] else parts[1])
# Also capture full dotted path for cross-module check
imports.add(parts[1])
return imports
def _import_lines(src_file: str) -> list[str]:
"""Return only import-statement lines from a Python source file."""
lines = []
with open(src_file) as f:
for line in f:
stripped = line.strip()
if stripped.startswith("from ") or stripped.startswith("import "):
lines.append(stripped)
return lines
def test_no_prompts_import_in_llm_tree():
"""markitect.llm must not import anything from markitect.prompts.*"""
violations = []
for mod_name in _collect_llm_modules():
try:
mod = importlib.import_module(mod_name)
except ImportError:
continue
src_file = getattr(mod, "__file__", None)
if not src_file or not src_file.endswith(".py"):
continue
for line in _import_lines(src_file):
if "markitect.prompts" in line:
violations.append(mod_name)
break
assert violations == [], (
f"These llm modules still import from markitect.prompts: {violations}"
)
def test_no_infospace_import_in_llm_tree():
"""markitect.llm must not import anything from markitect.infospace.*"""
violations = []
for mod_name in _collect_llm_modules():
try:
mod = importlib.import_module(mod_name)
except ImportError:
continue
src_file = getattr(mod, "__file__", None)
if not src_file or not src_file.endswith(".py"):
continue
for line in _import_lines(src_file):
if "markitect.infospace" in line:
violations.append(mod_name)
break
assert violations == [], (
f"These llm modules still import from markitect.infospace: {violations}"
)
def test_runconfig_and_llmresponse_canonical_in_llm():
"""RunConfig and LLMResponse must be defined in markitect.llm.models."""
from markitect.llm.models import RunConfig, LLMResponse
assert RunConfig.__module__ == "markitect.llm.models", (
f"RunConfig.module = {RunConfig.__module__!r}, expected 'markitect.llm.models'"
)
assert LLMResponse.__module__ == "markitect.llm.models", (
f"LLMResponse.module = {LLMResponse.__module__!r}, expected 'markitect.llm.models'"
)
def test_llmadapter_canonical_in_llm():
"""LLMAdapter must be defined in markitect.llm.adapter."""
from markitect.llm.adapter import LLMAdapter
assert LLMAdapter.__module__ == "markitect.llm.adapter", (
f"LLMAdapter.module = {LLMAdapter.__module__!r}, expected 'markitect.llm.adapter'"
)
def test_backward_compat_prompts_reexport():
"""markitect.prompts.execution.models must still export RunConfig/LLMResponse."""
from markitect.prompts.execution.models import RunConfig, LLMResponse
from markitect.llm.models import RunConfig as RC, LLMResponse as LR
assert RunConfig is RC, "prompts re-export RunConfig must be the same object as llm.models.RunConfig"
assert LLMResponse is LR, "prompts re-export LLMResponse must be the same object as llm.models.LLMResponse"
def test_backward_compat_llmadapter_reexport():
"""markitect.prompts.execution.llm_adapter must still export LLMAdapter."""
from markitect.prompts.execution.llm_adapter import LLMAdapter
from markitect.llm.adapter import LLMAdapter as LA
assert LLMAdapter is LA, "prompts re-export LLMAdapter must be the same object as llm.adapter.LLMAdapter"
def test_app_name_parameterization():
"""resolve_llm(app_name=X) uses ~/.config/X/config.toml and X_HELPER_MODEL."""
from markitect.llm.toml_config import (
_model_env_var,
_user_config_path,
_dir_config_name,
resolve_llm,
)
assert _model_env_var("railiance") == "RAILIANCE_HELPER_MODEL"
assert _model_env_var("markitect") == "MARKITECT_HELPER_MODEL"
assert str(_user_config_path("railiance")).endswith(".config/railiance/config.toml")
assert _dir_config_name("railiance") == ".railiance.toml"
# Smoke: resolve falls back to hardcoded for unknown app
r = resolve_llm(app_name="nonexistent_app_xyz")
assert r.provider_source == "hardcoded"
assert r.model_source == "hardcoded"