Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 49 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,22 +532,32 @@ Quality gates run **before** marking tasks complete, preventing bad code from be
```python
# In WorkerAgent.complete_task()
async def complete_task(self, task: Task) -> TaskResult:
# Stage 1: Run tests
test_result = await self._run_tests(task)
if not test_result.passed:
return self._create_blocker(task, "Tests failed", test_result)
# Stage 1: Linting (fast, catches obvious issues)
linting_result = await self._run_linting_gate(task)
if not linting_result.passed:
return self._create_blocker(task, "Linting errors", linting_result)

# Stage 2: Type checking
# Stage 2: Type checking (fast, catches type errors)
type_result = await self._run_type_check(task)
if not type_result.passed:
return self._create_blocker(task, "Type errors", type_result)

# Stage 3: Coverage check
# Stage 3: Skip detection (fast, scans for test skips)
skip_result = await self._run_skip_detection_gate(task)
if not skip_result.passed:
return self._create_blocker(task, "Skip patterns found", skip_result)

# Stage 4: Run tests (slower, validates functionality)
test_result = await self._run_tests(task)
if not test_result.passed:
return self._create_blocker(task, "Tests failed", test_result)

# Stage 5: Coverage check (runs with tests, checks coverage)
coverage = await self._check_coverage(task)
if coverage < 0.85:
return self._create_blocker(task, f"Coverage {coverage}% < 85%")

# Stage 4: Code review (Review Agent)
# Stage 6: Code review (slowest, deep code analysis)
review_result = await self._trigger_review_agent(task)
if review_result.has_critical_issues:
return self._create_blocker(task, "Critical review findings", review_result)
Expand All @@ -556,6 +566,33 @@ async def complete_task(self, task: Task) -> TaskResult:
return TaskResult(status="completed")
```

#### Skip Detection Gate

The skip detection gate scans test files for skip patterns across multiple languages, preventing tests from being bypassed. This gate can be disabled via environment variable if needed.

**Supported Languages:**
- Python: `@skip`, `@pytest.mark.skip`, `@unittest.skip`
- JavaScript/TypeScript: `it.skip`, `test.skip`, `describe.skip`, `xit`, `xtest`
- Go: `t.Skip()`, `testing.Skip()`, build tags
- Rust: `#[ignore]`
- Java: `@Ignore`, `@Disabled`
- Ruby: `skip`, `pending`, `xit`
- C#: `[Ignore]`, `[Skip]`

**Configuration:**
```bash
# Enable/disable skip detection (default: enabled)
export CODEFRAME_ENABLE_SKIP_DETECTION=true # or false
```

**Example violation:**
```python
# This will trigger the skip detection gate:
@pytest.mark.skip(reason="TODO: fix flaky test")
def test_payment_processing():
assert process_payment(100) == "success"
```

#### API Usage
```bash
# Get quality gate status for a task
Expand All @@ -570,12 +607,13 @@ POST /api/tasks/{task_id}/quality-gates?project_id=1
"status": "failed", # or "passed"
"failures": [
{
"gate": "tests",
"reason": "3 tests failed",
"details": "test_auth.py::test_login FAILED\n..."
"gate": "skip_detection",
"reason": "Skip pattern found in tests/test_payment.py:42 - @pytest.mark.skip",
"details": "File: tests/test_payment.py:42\nPattern: @pytest.mark.skip\nContext: @pytest.mark.skip(reason='TODO: fix flaky test')\nReason: TODO: fix flaky test",
"severity": "high"
}
],
"execution_time_seconds": 45.2
"execution_time_seconds": 0.15
}
```

Expand Down
15 changes: 15 additions & 0 deletions codeframe/config/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class SecurityPolicy:
custom_safe_commands: Additional commands to consider safe
blocked_commands: Commands to explicitly block
max_command_length: Maximum allowed command length
enable_skip_detection: Whether to enable skip pattern detection in quality gates
"""

enforcement_level: SecurityEnforcement = SecurityEnforcement.WARN
Expand All @@ -63,6 +64,7 @@ class SecurityPolicy:
custom_safe_commands: Set[str] = None
blocked_commands: Set[str] = None
max_command_length: int = 1000
enable_skip_detection: bool = True

def __post_init__(self):
if self.custom_safe_commands is None:
Expand Down Expand Up @@ -127,12 +129,16 @@ def from_environment(cls) -> "SecurityConfig":
os.getenv("CODEFRAME_ALLOW_SHELL_OPERATORS", "true").lower() == "true"
)
safe_commands_only = os.getenv("CODEFRAME_SAFE_COMMANDS_ONLY", "false").lower() == "true"
enable_skip_detection = (
os.getenv("CODEFRAME_ENABLE_SKIP_DETECTION", "true").lower() == "true"
)

# Create policy
policy = SecurityPolicy(
enforcement_level=enforcement,
allow_shell_operators=allow_shell_operators,
safe_commands_only=safe_commands_only,
enable_skip_detection=enable_skip_detection,
)

return cls(deployment_mode=deployment_mode, policy=policy)
Expand Down Expand Up @@ -202,6 +208,15 @@ def should_log_security_warnings(self) -> bool:
"""
return self.policy.enforcement_level != SecurityEnforcement.DISABLED

def should_enable_skip_detection(self) -> bool:
"""
Determine if skip pattern detection should be enabled.

Returns:
True if skip detection should run in quality gates
"""
return self.policy.enable_skip_detection


# Global security config instance
_security_config: Optional[SecurityConfig] = None
Expand Down
11 changes: 6 additions & 5 deletions codeframe/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class Project:
paused_at: Optional[datetime] = None
config: Optional[Dict[str, Any]] = None

def to_dict(self) -> dict:
def to_dict(self) -> Dict[str, Any]:
"""Convert Project to dictionary for JSON serialization."""
return {
"id": self.id,
Expand Down Expand Up @@ -163,6 +163,7 @@ class QualityGateType(str, Enum):
COVERAGE = "coverage"
CODE_REVIEW = "code_review"
LINTING = "linting"
SKIP_DETECTION = "skip_detection"


class CallType(str, Enum):
Expand Down Expand Up @@ -193,7 +194,7 @@ class Issue:
created_at: datetime = field(default_factory=datetime.now)
completed_at: Optional[datetime] = None

def to_dict(self) -> dict:
def to_dict(self) -> Dict[str, Any]:
"""Convert Issue to dictionary for JSON serialization."""
return {
"id": self.id,
Expand Down Expand Up @@ -245,7 +246,7 @@ def title(self) -> str:
def status(self) -> TaskStatus:
return self.issue.status

def to_dict(self) -> dict:
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = self.issue.to_dict()
result["task_count"] = self.task_count
Expand Down Expand Up @@ -280,7 +281,7 @@ class Task:
created_at: datetime = field(default_factory=datetime.now)
completed_at: Optional[datetime] = None

def to_dict(self) -> dict:
def to_dict(self) -> Dict[str, Any]:
"""Convert Task to dictionary for JSON serialization."""
return {
"id": self.id,
Expand Down Expand Up @@ -627,7 +628,7 @@ def total_issues(self) -> int:
"""Total issues (errors + warnings)."""
return self.error_count + self.warning_count

def to_dict(self) -> dict:
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for database storage."""
return {
"task_id": self.task_id,
Expand Down
17 changes: 13 additions & 4 deletions codeframe/core/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,11 +461,15 @@ def get_status(self) -> dict:
def get_status_value(status):
return status.value if hasattr(status, "value") else str(status)

# Load project config once at the top (before any DB access or fallbacks)
project_config = self.config.load()

# Handle case where database is not initialized
if not self.db:
logger.warning("Database not initialized, returning minimal status")
return {
"project_name": self.config.load().project_name,
"id": None,
"name": project_config.project_name,
"status": get_status_value(self._status),
"tasks": {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "pending": 0},
"agents": {"active": 0, "idle": 0, "total": 0},
Expand All @@ -477,7 +481,6 @@ def get_status_value(status):

try:
# Step 1: Get project ID and metadata from database
project_config = self.config.load()
cursor = self.db.conn.cursor()
cursor.execute(
"SELECT id, name, status, created_at FROM projects WHERE name = ?",
Expand All @@ -488,7 +491,8 @@ def get_status_value(status):
if not row:
logger.warning(f"Project '{project_config.project_name}' not found in database")
return {
"project_name": project_config.project_name,
"id": None,
"name": project_config.project_name,
"status": get_status_value(self._status),
"tasks": {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "pending": 0},
"agents": {"active": 0, "idle": 0, "total": 0},
Expand Down Expand Up @@ -590,9 +594,11 @@ def get_status_value(status):

except Exception as e:
# Step 9: Error handling - never raise exceptions, always return valid dict
# Use project_config loaded at the top to preserve original exception context
logger.error(f"Error retrieving project status: {e}", exc_info=True)
return {
"project_name": self.config.load().project_name if self.config else "Unknown",
"id": None,
"name": project_config.project_name,
"status": get_status_value(self._status),
"tasks": {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "pending": 0},
"agents": {"active": 0, "idle": 0, "total": 0},
Expand Down Expand Up @@ -620,6 +626,9 @@ def _format_time_ago(self, timestamp_str: str) -> str:
# ISO format: 2025-12-18T10:30:00Z or 2025-12-18T10:30:00+00:00
timestamp_str = timestamp_str.replace('Z', '+00:00')
timestamp = datetime.fromisoformat(timestamp_str)
# Ensure timezone-aware (add UTC if naive)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
else:
# SQLite format: 2025-12-18 10:30:00
timestamp = datetime.fromisoformat(timestamp_str).replace(tzinfo=timezone.utc)
Expand Down
8 changes: 4 additions & 4 deletions codeframe/lib/metrics_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,11 @@ async def get_project_costs(self, project_id: int) -> Dict[str, Any]:
# Convert to lists and round costs
result["total_cost_usd"] = round(result["total_cost_usd"], 6) # type: ignore[call-overload]
result["by_agent"] = [
{**stats, "cost_usd": round(stats["cost_usd"], 6)} # type: ignore[call-overload]
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
for stats in agent_stats.values()
]
result["by_model"] = [
{**stats, "cost_usd": round(stats["cost_usd"], 6)} # type: ignore[call-overload]
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
for stats in model_stats.values()
]

Expand Down Expand Up @@ -376,11 +376,11 @@ async def get_agent_costs(self, agent_id: str) -> Dict[str, Any]:
# Convert to lists and round costs
result["total_cost_usd"] = round(result["total_cost_usd"], 6) # type: ignore[call-overload]
result["by_call_type"] = [
{**stats, "cost_usd": round(stats["cost_usd"], 6)} # type: ignore[call-overload]
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
for stats in call_type_stats.values()
]
result["by_project"] = [
{**stats, "cost_usd": round(stats["cost_usd"], 6)} # type: ignore[call-overload]
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
for stats in project_stats.values()
]

Expand Down
Loading
Loading