8604bc66b6
ISSUES CLOSED: #320
455 lines
17 KiB
Python
455 lines
17 KiB
Python
"""Step definitions for architecture validation."""
|
|
|
|
import ast
|
|
import importlib
|
|
import pkgutil
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from behave import given, then, when
|
|
|
|
|
|
@given('the ADR directory exists at "{path}"')
|
|
def step_adr_directory_exists(context, path):
|
|
"""Check if the ADR directory exists."""
|
|
context.adr_dir = Path(path)
|
|
assert context.adr_dir.exists(), f"ADR directory {path} does not exist"
|
|
assert context.adr_dir.is_dir(), f"{path} is not a directory"
|
|
|
|
|
|
@given('the ADR directory "{path}" may be absent')
|
|
def step_adr_directory_may_be_absent(context, path):
|
|
"""Record the ADR directory path; it may not exist."""
|
|
context.adr_dir = Path(path)
|
|
context.adr_dir_exists = context.adr_dir.exists() and context.adr_dir.is_dir()
|
|
|
|
|
|
@then("the ADR check should be skipped when the directory is missing")
|
|
def step_adr_skip_when_missing(context):
|
|
"""Pass when the ADR directory is absent (docs removed from repo)."""
|
|
if context.adr_dir_exists:
|
|
# If someone recreates the directory, this test still passes
|
|
adr_files = list(context.adr_dir.glob("ADR-*.md"))
|
|
assert len(adr_files) >= 0 # Just verify glob works
|
|
# When directory is missing, this step passes (expected state)
|
|
|
|
|
|
@when("I check for ADR files")
|
|
def step_check_adr_files(context):
|
|
"""Find all ADR files in the directory."""
|
|
context.adr_files = list(context.adr_dir.glob("ADR-*.md"))
|
|
|
|
|
|
@then("I should find at least {count:d} ADR files")
|
|
def step_verify_adr_count(context, count):
|
|
"""Verify minimum number of ADR files."""
|
|
assert len(context.adr_files) >= count, (
|
|
f"Expected at least {count} ADR files, found {len(context.adr_files)}"
|
|
)
|
|
|
|
|
|
@then("each ADR should have a valid format with status")
|
|
def step_verify_adr_format(context):
|
|
"""Verify each ADR follows the expected format."""
|
|
for adr_file in context.adr_files:
|
|
content = adr_file.read_text()
|
|
# Check for required sections
|
|
assert "## Status" in content, f"{adr_file.name} missing Status section"
|
|
assert "## Context" in content, f"{adr_file.name} missing Context section"
|
|
assert "## Decision" in content, f"{adr_file.name} missing Decision section"
|
|
assert "## Consequences" in content, (
|
|
f"{adr_file.name} missing Consequences section"
|
|
)
|
|
|
|
|
|
@given('the source directory exists at "{path}"')
|
|
def step_source_directory_exists(context, path):
|
|
"""Check if the source directory exists."""
|
|
context.src_dir = Path(path)
|
|
assert context.src_dir.exists(), f"Source directory {path} does not exist"
|
|
|
|
|
|
@when("I check the package structure")
|
|
def step_check_package_structure(context):
|
|
"""List all packages in the source directory."""
|
|
try:
|
|
# Ensure the directory exists
|
|
if not context.src_dir.exists():
|
|
raise AssertionError(f"Source directory {context.src_dir} does not exist")
|
|
|
|
# List all items in the directory with better error handling
|
|
context.packages = {}
|
|
for item in context.src_dir.iterdir():
|
|
try:
|
|
if item.is_dir() and not item.name.startswith("__"):
|
|
# Store all packages including discovery
|
|
# Handle expected vs actual in verification
|
|
context.packages[item.name] = item
|
|
except (OSError, PermissionError) as e:
|
|
# Log the error but continue processing other items
|
|
print(f"Warning: Could not check {item}: {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
raise AssertionError(f"Failed to check package structure: {e}") from e
|
|
|
|
|
|
@then("I should find these main packages:")
|
|
def step_verify_packages(context):
|
|
"""Verify expected packages exist and are proper Python packages."""
|
|
# Check if packages were properly collected
|
|
if not hasattr(context, "packages"):
|
|
raise AssertionError(
|
|
"Package structure was not checked properly - 'packages' attribute missing"
|
|
)
|
|
|
|
# Parse the table to get expected packages and their descriptions
|
|
expected_packages = {
|
|
row["package"]: row.get("description", "") for row in context.table
|
|
}
|
|
actual_packages = set(context.packages.keys())
|
|
|
|
# Check for missing packages
|
|
missing = set(expected_packages.keys()) - actual_packages
|
|
if missing:
|
|
extra = actual_packages - set(expected_packages.keys())
|
|
error_msg = f"Missing packages: {missing}"
|
|
if extra:
|
|
error_msg += f"\nUnexpected packages found: {extra}"
|
|
error_msg += f"\nActual packages: {sorted(actual_packages)}"
|
|
error_msg += f"\nExpected packages: {sorted(expected_packages.keys())}"
|
|
raise AssertionError(error_msg)
|
|
|
|
# Verify each expected package is a proper Python package
|
|
for package_name in expected_packages:
|
|
package_path = context.packages[package_name]
|
|
init_file = package_path / "__init__.py"
|
|
if not init_file.exists():
|
|
raise AssertionError(
|
|
f"Package '{package_name}' at {package_path} is not a "
|
|
f"proper Python package (__init__.py not found)"
|
|
)
|
|
|
|
|
|
@given("the package structure is defined")
|
|
def step_package_structure_defined(context):
|
|
"""Set up package structure for import analysis."""
|
|
context.src_dir = Path("src/cleveragents")
|
|
context.packages = {
|
|
p.name: p
|
|
for p in context.src_dir.iterdir()
|
|
if p.is_dir() and not p.name.startswith("__")
|
|
}
|
|
|
|
|
|
@when("I analyze package imports")
|
|
def step_analyze_imports(context):
|
|
"""Analyze imports in all Python files."""
|
|
context.imports = {}
|
|
for package_name, package_path in context.packages.items():
|
|
package_imports = set()
|
|
for py_file in package_path.rglob("*.py"):
|
|
if py_file.name == "__init__.py" and py_file.read_text().strip() == "":
|
|
continue
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name.startswith("cleveragents."):
|
|
package_imports.add(alias.name.split(".")[1])
|
|
elif (
|
|
isinstance(node, ast.ImportFrom)
|
|
and node.module
|
|
and node.module.startswith("cleveragents.")
|
|
):
|
|
package_imports.add(node.module.split(".")[1])
|
|
except SyntaxError:
|
|
pass # Skip files with syntax errors
|
|
context.imports[package_name] = package_imports
|
|
|
|
|
|
@then("{package} should not import from {target}")
|
|
def step_verify_no_import(context, package, target):
|
|
"""Verify a package doesn't import from another."""
|
|
if package not in context.imports:
|
|
return # Package doesn't exist yet
|
|
imports = context.imports.get(package, set())
|
|
assert target not in imports, f"{package} imports from {target}"
|
|
|
|
|
|
@then("the core package should be self-contained")
|
|
def step_verify_core_self_contained(context):
|
|
"""Verify core package doesn't import from any other internal package."""
|
|
package = "core"
|
|
if package not in context.imports:
|
|
return # Package doesn't exist yet
|
|
imports = context.imports.get(package, set())
|
|
# Filter out self-imports and allowed imports
|
|
# Core can import from config for dependency injection container setup
|
|
# and from shared for cross-cutting utilities (redaction, etc.)
|
|
allowed_imports = {"core", "config", "shared"}
|
|
other_imports = imports - allowed_imports
|
|
assert not other_imports, f"{package} imports from: {other_imports}"
|
|
|
|
|
|
@given("the settings module exists")
|
|
def step_settings_module_exists(context):
|
|
"""Check if settings module exists."""
|
|
context.settings_file = Path("src/cleveragents/config/settings.py")
|
|
assert context.settings_file.exists(), "Settings module does not exist"
|
|
|
|
|
|
@when("I check environment variable definitions")
|
|
def step_check_env_vars(context):
|
|
"""Extract environment variable definitions from settings."""
|
|
content = context.settings_file.read_text()
|
|
# Find all environment variable references
|
|
context.env_vars = re.findall(r'["\']([A-Z_]+)["\']', content)
|
|
|
|
|
|
@then("all application variables should use {prefix} prefix")
|
|
def step_verify_env_prefix(context, prefix):
|
|
"""Verify application environment variables use correct prefix."""
|
|
# Provider prefixes that should be excluded
|
|
provider_prefixes = [
|
|
"OPENAI_",
|
|
"ANTHROPIC_",
|
|
"CLAUDE_",
|
|
"GEMINI_",
|
|
"AZURE_",
|
|
"GOOGLE_",
|
|
"OPENROUTER_",
|
|
"HF_",
|
|
"HUGGINGFACEHUB_",
|
|
"HUGGING_FACE_HUB_",
|
|
"COHERE_",
|
|
"PERPLEXITY_",
|
|
"GROQ_",
|
|
"TOGETHER_",
|
|
]
|
|
|
|
# Common constants and system variables to exclude
|
|
excluded_constants = [
|
|
"LANGCHAIN_PROJECT_TYPE", # LangChain internal constant
|
|
"LANGCHAIN_CHAT_MODEL", # LangChain testing constant
|
|
"LANGCHAIN_HANDLER", # LangChain handler prefix
|
|
"INFO", # Logging level constants pulled from settings
|
|
"DEBUG",
|
|
"WARNING",
|
|
"ERROR",
|
|
"CRITICAL",
|
|
]
|
|
|
|
langsmith_allowlist = {
|
|
# LangChain/LangSmith compatibility variables that Settings syncs automatically
|
|
"LANGCHAIN_TRACING_V2",
|
|
"LANGCHAIN_API_KEY",
|
|
"LANGCHAIN_PROJECT",
|
|
"LANGCHAIN_ENDPOINT",
|
|
"LANGSMITH_TRACING_V2",
|
|
"LANGSMITH_API_KEY",
|
|
"LANGSMITH_PROJECT",
|
|
"LANGSMITH_ENDPOINT",
|
|
"LANGSMITH_USER_ID",
|
|
}
|
|
|
|
app_vars = [
|
|
var
|
|
for var in context.env_vars
|
|
if not any(var.startswith(p) for p in provider_prefixes)
|
|
and var not in excluded_constants
|
|
]
|
|
|
|
invalid_vars = [
|
|
var
|
|
for var in app_vars
|
|
if not var.startswith(prefix) and var not in langsmith_allowlist
|
|
]
|
|
assert not invalid_vars, f"Variables not using {prefix} prefix: {invalid_vars}"
|
|
|
|
|
|
@then("provider variables should keep their original prefix")
|
|
def step_verify_provider_vars(context):
|
|
"""Verify provider variables keep their original prefix."""
|
|
provider_prefixes = [
|
|
"OPENAI_",
|
|
"ANTHROPIC_",
|
|
"CLAUDE_",
|
|
"GEMINI_",
|
|
"AZURE_",
|
|
"GOOGLE_",
|
|
"OPENROUTER_",
|
|
"HF_",
|
|
"HUGGINGFACEHUB_",
|
|
"HUGGING_FACE_HUB_",
|
|
"COHERE_",
|
|
"PERPLEXITY_",
|
|
"GROQ_",
|
|
"TOGETHER_",
|
|
]
|
|
provider_vars = [
|
|
var
|
|
for var in context.env_vars
|
|
if any(var.startswith(p) for p in provider_prefixes)
|
|
]
|
|
# Just verify they exist and aren't renamed
|
|
for var in provider_vars:
|
|
assert not var.startswith("CLEVERAGENTS_"), f"Provider var renamed: {var}"
|
|
|
|
|
|
@given("the source code exists")
|
|
def step_source_code_exists(context):
|
|
"""Set up source code for analysis."""
|
|
context.src_dir = Path("src/cleveragents")
|
|
assert context.src_dir.exists()
|
|
|
|
|
|
@when("I check for type annotations")
|
|
def step_check_type_annotations(context):
|
|
"""Analyze type annotations in the codebase."""
|
|
context.functions_with_hints = 0
|
|
context.functions_without_hints = 0
|
|
context.pydantic_models = 0
|
|
context.regular_dataclasses = 0
|
|
|
|
for py_file in context.src_dir.rglob("*.py"):
|
|
if "discovery" in str(py_file):
|
|
continue # Skip discovery module as per pyright config
|
|
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef):
|
|
# Check if function has return type annotation
|
|
if node.returns is not None:
|
|
context.functions_with_hints += 1
|
|
else:
|
|
# Skip __init__, __str__, __repr__ methods
|
|
if not node.name.startswith("_"):
|
|
context.functions_without_hints += 1
|
|
|
|
elif isinstance(node, ast.ClassDef):
|
|
# Check if it's a Pydantic model or dataclass
|
|
for base in node.bases:
|
|
base_name = (
|
|
ast.unparse(base) if hasattr(ast, "unparse") else str(base)
|
|
)
|
|
if "BaseModel" in base_name or "BaseSettings" in base_name:
|
|
context.pydantic_models += 1
|
|
elif "dataclass" in [
|
|
d.id for d in node.decorator_list if isinstance(d, ast.Name)
|
|
]:
|
|
context.regular_dataclasses += 1
|
|
except Exception:
|
|
pass # Skip files with syntax errors
|
|
|
|
|
|
@then("all public functions should have type hints")
|
|
def step_verify_public_functions_type_hints(context):
|
|
"""Verify public functions have type hints."""
|
|
missing_annotations = []
|
|
for py_file in context.src_dir.rglob("*.py"):
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
except SyntaxError:
|
|
continue
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
|
|
args = node.args
|
|
has_annotations = True
|
|
for arg in args.args + args.kwonlyargs:
|
|
if arg.arg not in ("self", "cls") and arg.annotation is None:
|
|
has_annotations = False
|
|
break
|
|
if args.vararg and args.vararg.annotation is None:
|
|
has_annotations = False
|
|
if args.kwarg and args.kwarg.annotation is None:
|
|
has_annotations = False
|
|
if node.returns is None:
|
|
has_annotations = False
|
|
if not has_annotations:
|
|
missing_annotations.append(f"{py_file}:{node.name}")
|
|
|
|
assert not missing_annotations, (
|
|
"Public functions missing type hints:\n" + "\n".join(missing_annotations)
|
|
)
|
|
|
|
|
|
@then("all dataclasses should use Pydantic models")
|
|
def step_verify_dataclasses_pydantic(context):
|
|
"""Verify dataclasses are Pydantic models."""
|
|
missing_pydantic = []
|
|
for py_file in context.src_dir.rglob("*.py"):
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
except SyntaxError:
|
|
continue
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef):
|
|
bases = [base.id for base in node.bases if isinstance(base, ast.Name)]
|
|
if "BaseModel" in bases:
|
|
continue
|
|
decorators = [
|
|
decorator.id
|
|
for decorator in node.decorator_list
|
|
if isinstance(decorator, ast.Name)
|
|
]
|
|
if "dataclass" in decorators:
|
|
missing_pydantic.append(f"{py_file}:{node.name}")
|
|
|
|
assert not missing_pydantic, (
|
|
"Dataclasses missing Pydantic BaseModel inheritance:\n"
|
|
+ "\n".join(missing_pydantic)
|
|
)
|
|
|
|
|
|
@when("I read the CONTRIBUTING guidelines")
|
|
def step_read_contributing_guidelines(context):
|
|
"""Load the CONTRIBUTING.md document."""
|
|
contributing_path = Path("CONTRIBUTING.md")
|
|
assert contributing_path.exists(), "CONTRIBUTING.md not found"
|
|
context.contributing_text = contributing_path.read_text(encoding="utf-8")
|
|
|
|
|
|
@then('the contributing document should include "{text}"')
|
|
def step_contributing_includes_text(context, text):
|
|
"""Ensure CONTRIBUTING.md contains the expected text."""
|
|
content = getattr(context, "contributing_text", "")
|
|
assert text in content, f"Expected '{text}' in CONTRIBUTING.md"
|
|
|
|
|
|
@when("I import every module under the source directory")
|
|
def step_import_every_module(context):
|
|
"""Import every module beneath src/cleveragents so coverage sees them."""
|
|
|
|
if not hasattr(context, "src_dir"):
|
|
context.src_dir = Path("src/cleveragents")
|
|
assert context.src_dir.exists(), "Source directory missing"
|
|
|
|
importlib.import_module("cleveragents")
|
|
|
|
context.module_import_errors = []
|
|
package_root = str(context.src_dir)
|
|
|
|
for module_info in pkgutil.walk_packages([package_root], prefix="cleveragents."):
|
|
module_name = module_info.name
|
|
if module_name.endswith(".__main__"):
|
|
continue
|
|
try:
|
|
importlib.import_module(module_name)
|
|
except Exception as exc: # pragma: no cover - only runs on failure
|
|
context.module_import_errors.append((module_name, repr(exc)))
|
|
|
|
|
|
@then("every module should import without errors")
|
|
def step_assert_module_imports(context):
|
|
"""Fail if any module imports raised an exception."""
|
|
|
|
errors = getattr(context, "module_import_errors", [])
|
|
assert not errors, "Some modules failed to import:\n" + "\n".join(
|
|
f"- {name}: {error}" for name, error in errors
|
|
)
|