feat: Add Q0: Pre-commit hooks setup.
This should automatically check for problems on build.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
## Description
|
||||
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] Documentation update
|
||||
- [ ] Test improvements
|
||||
- [ ] CI/CD changes
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
- [ ] Code follows the project's coding standards (see CONTRIBUTING.md)
|
||||
- [ ] All public/protected methods have argument validation
|
||||
- [ ] Static typing is complete (no `Any` unless justified)
|
||||
- [ ] `nox -s typecheck` passes with no errors
|
||||
- [ ] `nox -s lint` passes with no errors
|
||||
- [ ] Unit tests written/updated (Behave scenarios in `features/`)
|
||||
- [ ] Integration tests written/updated (Robot suites in `robot/`) if applicable
|
||||
- [ ] Coverage remains above 85% (`nox -s coverage_report`)
|
||||
- [ ] No security issues introduced (`nox -s security_scan`)
|
||||
- [ ] No dead code introduced (`nox -s dead_code`)
|
||||
- [ ] Documentation updated if behavior changed
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
### Test Commands Run
|
||||
|
||||
```bash
|
||||
nox -s unit_tests # Behave tests
|
||||
nox -s typecheck # Type checking
|
||||
nox -s lint # Linting
|
||||
nox -s coverage_report # Coverage
|
||||
```
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link to related issues: Closes #123, Fixes #456 -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- Any architectural decisions, trade-offs, or important notes for reviewers -->
|
||||
+113
-20
@@ -17,19 +17,19 @@ jobs:
|
||||
image: python:3.13-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system -e .[dev]
|
||||
|
||||
|
||||
- name: Run ruff format check
|
||||
run: |
|
||||
ruff format --check .
|
||||
|
||||
|
||||
- name: Run ruff lint
|
||||
run: |
|
||||
ruff check .
|
||||
@@ -40,19 +40,79 @@ jobs:
|
||||
image: python:3.13-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system -e .[dev]
|
||||
|
||||
|
||||
- name: Run pyright
|
||||
run: |
|
||||
pyright
|
||||
|
||||
security:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: python:3.13-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system -e .[dev]
|
||||
|
||||
- name: Run bandit security scan
|
||||
run: |
|
||||
bandit -c pyproject.toml -r src/cleveragents --severity-level medium --format json --output build/bandit-report.json || true
|
||||
bandit -c pyproject.toml -r src/cleveragents --severity-level high
|
||||
|
||||
- name: Run vulture dead code detection
|
||||
run: |
|
||||
vulture src/cleveragents vulture_whitelist.py --min-confidence 80 --exclude src/cleveragents/discovery
|
||||
|
||||
- name: Upload security report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: security-report
|
||||
path: build/bandit-report.json
|
||||
|
||||
quality:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: python:3.13-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system -e .[dev]
|
||||
|
||||
- name: Check code complexity
|
||||
run: |
|
||||
radon cc src/cleveragents --min C --show-complexity --total-average --json > build/complexity-report.json || true
|
||||
radon cc src/cleveragents --min F --show-complexity
|
||||
echo "Complexity check complete. Methods with F grade will fail the build."
|
||||
radon cc src/cleveragents --min F --show-complexity --json | python -c "import sys, json; data = json.load(sys.stdin); blocks = [b for f in data.values() for b in f]; sys.exit(1 if blocks else 0)"
|
||||
|
||||
- name: Upload complexity report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: complexity-report
|
||||
path: build/complexity-report.json
|
||||
|
||||
behave:
|
||||
runs-on: docker
|
||||
strategy:
|
||||
@@ -62,35 +122,68 @@ jobs:
|
||||
image: python:${{ matrix.python-version }}-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system -e .[dev]
|
||||
|
||||
|
||||
- name: Run behaviour tests
|
||||
run: |
|
||||
behave -q
|
||||
|
||||
coverage:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: python:3.13-slim
|
||||
needs: [lint, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system -e ".[dev,tests]"
|
||||
|
||||
- name: Install behave-parallel
|
||||
run: |
|
||||
pip install -q behave-parallel
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
coverage run --source=src -m behave -q --no-capture || true
|
||||
coverage xml -o build/coverage.xml
|
||||
coverage report --fail-under=85
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: build/coverage.xml
|
||||
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: python:3.13-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
|
||||
- name: Build wheel
|
||||
run: |
|
||||
uv pip install --system build
|
||||
python -m build --wheel
|
||||
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -98,31 +191,31 @@ jobs:
|
||||
path: dist/
|
||||
|
||||
docker:
|
||||
needs: [lint, typecheck, behave]
|
||||
needs: [lint, typecheck, behave, security]
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t cleverernie:test .
|
||||
|
||||
|
||||
- name: Test Docker image
|
||||
run: |
|
||||
docker run --rm cleverernie:test --version
|
||||
|
||||
helm:
|
||||
needs: [lint, typecheck, behave]
|
||||
needs: [lint, typecheck, behave, security]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: alpine/helm:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Lint Helm chart
|
||||
run: |
|
||||
helm lint k8s/
|
||||
|
||||
|
||||
- name: Template Helm chart
|
||||
run: |
|
||||
helm template test k8s/
|
||||
helm template test k8s/
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Nightly Quality
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at midnight UTC every day
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
# Allow manual trigger for testing
|
||||
|
||||
env:
|
||||
UV_VERSION: "0.8.0"
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
jobs:
|
||||
full-quality-suite:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: python:3.13-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install -q uv==${{ env.UV_VERSION }}
|
||||
|
||||
- name: Install all dependencies
|
||||
run: |
|
||||
uv pip install --system -e ".[dev,tests]"
|
||||
|
||||
- name: Install behave-parallel
|
||||
run: |
|
||||
pip install -q behave-parallel
|
||||
|
||||
- name: Run full lint suite
|
||||
run: |
|
||||
ruff format --check .
|
||||
ruff check .
|
||||
|
||||
- name: Run pyright type checking
|
||||
run: |
|
||||
pyright
|
||||
|
||||
- name: Run bandit security scan (all severities)
|
||||
run: |
|
||||
mkdir -p build/reports
|
||||
bandit -c pyproject.toml -r src/cleveragents --format json --output build/reports/bandit-full.json || true
|
||||
bandit -c pyproject.toml -r src/cleveragents --severity-level high
|
||||
|
||||
- name: Run vulture dead code detection
|
||||
run: |
|
||||
vulture src/cleveragents vulture_whitelist.py --min-confidence 80 --exclude src/cleveragents/discovery
|
||||
|
||||
- name: Run radon complexity analysis
|
||||
run: |
|
||||
mkdir -p build/reports
|
||||
radon cc src/cleveragents --show-complexity --total-average --json > build/reports/complexity.json
|
||||
radon mi src/cleveragents --json > build/reports/maintainability.json
|
||||
echo "=== Complexity Summary ==="
|
||||
radon cc src/cleveragents --min C --show-complexity --total-average
|
||||
echo "=== Maintainability Index ==="
|
||||
radon mi src/cleveragents --min B
|
||||
|
||||
- name: Run full Behave test suite with coverage
|
||||
run: |
|
||||
coverage run --source=src -m behave -q --no-capture || true
|
||||
coverage xml -o build/reports/coverage.xml
|
||||
coverage json -o build/reports/coverage.json
|
||||
coverage report --fail-under=85
|
||||
|
||||
- name: Run quality gates script
|
||||
run: |
|
||||
python scripts/check-quality-gates.py --coverage-min 85 --complexity-max F
|
||||
|
||||
- name: Generate quality trend data
|
||||
run: |
|
||||
python -c "
|
||||
import json, datetime
|
||||
from pathlib import Path
|
||||
|
||||
report = {
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'gates': {}
|
||||
}
|
||||
|
||||
# Coverage
|
||||
cov_path = Path('build/reports/coverage.json')
|
||||
if cov_path.exists():
|
||||
cov_data = json.loads(cov_path.read_text())
|
||||
report['gates']['coverage'] = cov_data.get('totals', {}).get('percent_covered', 0)
|
||||
|
||||
# Complexity
|
||||
cx_path = Path('build/reports/complexity.json')
|
||||
if cx_path.exists():
|
||||
cx_data = json.loads(cx_path.read_text())
|
||||
total_blocks = sum(len(b) for b in cx_data.values())
|
||||
report['gates']['total_analyzed_blocks'] = total_blocks
|
||||
|
||||
# Security
|
||||
sec_path = Path('build/reports/bandit-full.json')
|
||||
if sec_path.exists():
|
||||
sec_data = json.loads(sec_path.read_text())
|
||||
report['gates']['security_issues'] = len(sec_data.get('results', []))
|
||||
|
||||
Path('build/reports/quality-trend.json').write_text(json.dumps(report, indent=2))
|
||||
print(json.dumps(report, indent=2))
|
||||
"
|
||||
|
||||
- name: Upload quality reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-quality-reports
|
||||
path: build/reports/
|
||||
retention-days: 90
|
||||
@@ -0,0 +1,94 @@
|
||||
# Pre-commit configuration for CleverAgents
|
||||
# See https://pre-commit.com for more information
|
||||
# Install: pre-commit install
|
||||
# Run manually: pre-commit run --all-files
|
||||
|
||||
default_language_version:
|
||||
python: python3.13
|
||||
|
||||
repos:
|
||||
# ----- Branch Protection & General Checks -----
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: no-commit-to-branch
|
||||
name: Prevent direct commits to main
|
||||
args: ["--branch", "main"]
|
||||
- id: check-yaml
|
||||
name: Validate YAML syntax
|
||||
- id: check-toml
|
||||
name: Validate TOML syntax
|
||||
- id: check-json
|
||||
name: Validate JSON syntax
|
||||
exclude: "^\\.vscode/"
|
||||
- id: check-merge-conflict
|
||||
name: Check for merge conflicts
|
||||
- id: check-added-large-files
|
||||
name: Prevent large file commits
|
||||
args: ["--maxkb=500"]
|
||||
- id: end-of-file-fixer
|
||||
name: Fix end of file
|
||||
- id: trailing-whitespace
|
||||
name: Trim trailing whitespace
|
||||
args: ["--markdown-linebreak-ext=md"]
|
||||
- id: debug-statements
|
||||
name: Detect debug statements (pdb, breakpoint)
|
||||
|
||||
# ----- Ruff: Formatting & Linting -----
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.6
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
name: Ruff format (auto-fix)
|
||||
- id: ruff
|
||||
name: Ruff lint (auto-fix safe rules)
|
||||
args: ["--fix", "--exit-non-zero-on-fix"]
|
||||
|
||||
# ----- Bandit: Security Scanning -----
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.8.3
|
||||
hooks:
|
||||
- id: bandit
|
||||
name: Bandit security scan
|
||||
args: ["-c", "pyproject.toml", "-r", "--severity-level", "medium"]
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
files: "^src/"
|
||||
|
||||
# ----- Vulture: Dead Code Detection -----
|
||||
- repo: https://github.com/jendrikseipp/vulture
|
||||
rev: v2.14
|
||||
hooks:
|
||||
- id: vulture
|
||||
name: Vulture dead code detection
|
||||
args: ["src/cleveragents", "vulture_whitelist.py", "--min-confidence", "80", "--exclude", "src/cleveragents/discovery"]
|
||||
pass_filenames: false
|
||||
|
||||
# ----- Local Hooks (use installed tools) -----
|
||||
- repo: local
|
||||
hooks:
|
||||
# Pyright: Type Checking (runs serially, uses local installation)
|
||||
- id: pyright
|
||||
name: Pyright type checking
|
||||
language: system
|
||||
entry: pyright
|
||||
types: [python]
|
||||
files: "^src/"
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
# Semgrep: Advanced security scanning for eval/exec detection
|
||||
- id: semgrep-eval-exec
|
||||
name: Semgrep eval/exec detection
|
||||
language: system
|
||||
entry: scripts/run-semgrep.sh
|
||||
types: [python]
|
||||
files: "^src/"
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
# ----- Commit Message Linting -----
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.2.2
|
||||
hooks:
|
||||
- id: commitizen
|
||||
name: Conventional commit message check
|
||||
stages: [commit-msg]
|
||||
@@ -0,0 +1,54 @@
|
||||
rules:
|
||||
- id: no-eval
|
||||
pattern: eval(...)
|
||||
message: >
|
||||
Use of eval() is a security risk. Do not use eval() in production code.
|
||||
Consider using ast.literal_eval() for safe evaluation of literals.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
paths:
|
||||
include:
|
||||
- src/
|
||||
|
||||
- id: no-exec
|
||||
pattern: exec(...)
|
||||
message: >
|
||||
Use of exec() is a security risk. Do not use exec() in production code.
|
||||
Find an alternative approach that does not require dynamic code execution.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
paths:
|
||||
include:
|
||||
- src/
|
||||
|
||||
- id: no-compile-exec
|
||||
pattern: compile(..., ..., "exec")
|
||||
message: >
|
||||
Using compile() with exec mode is a security risk.
|
||||
Avoid dynamic code compilation in production code.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
paths:
|
||||
include:
|
||||
- src/
|
||||
|
||||
- id: no-os-system
|
||||
pattern: os.system(...)
|
||||
message: >
|
||||
Use of os.system() is insecure. Use subprocess.run() with shell=False instead.
|
||||
languages: [python]
|
||||
severity: WARNING
|
||||
paths:
|
||||
include:
|
||||
- src/
|
||||
|
||||
- id: no-pickle-loads
|
||||
pattern: pickle.loads(...)
|
||||
message: >
|
||||
Use of pickle.loads() on untrusted data is a security risk.
|
||||
Consider using json or a safe serialization format.
|
||||
languages: [python]
|
||||
severity: WARNING
|
||||
paths:
|
||||
include:
|
||||
- src/
|
||||
@@ -20,7 +20,10 @@ cd core
|
||||
# install dependencies
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .[tests,docs]
|
||||
pip install -e ".[dev,tests,docs]"
|
||||
|
||||
# set up pre-commit hooks and verify tooling
|
||||
bash scripts/setup-dev.sh
|
||||
|
||||
# verify the CLI
|
||||
agents --help
|
||||
@@ -29,15 +32,27 @@ agents --version
|
||||
|
||||
## Developing
|
||||
|
||||
Pre-commit hooks run automatically on every `git commit` (formatting, linting, type
|
||||
checking, security scanning). To run checks manually:
|
||||
|
||||
```bash
|
||||
# run validation suites
|
||||
nox -s format
|
||||
nox -s lint
|
||||
oxt -s typecheck
|
||||
nox -s unit_tests
|
||||
nox -s integration_tests
|
||||
# core validation
|
||||
nox -s format # ruff auto-formatting
|
||||
nox -s lint # ruff linting
|
||||
nox -s typecheck # pyright type checking
|
||||
nox -s unit_tests # behave unit tests
|
||||
nox -s integration_tests # robot integration tests
|
||||
|
||||
# quality & security
|
||||
nox -s security_scan # bandit security scanning
|
||||
nox -s dead_code # vulture dead code detection
|
||||
nox -s complexity # radon complexity analysis
|
||||
nox -s pre_commit # run all pre-commit hooks
|
||||
nox -s adr_compliance # verify ADR compliance
|
||||
```
|
||||
|
||||
For the full quality automation guide, see [`docs/development/quality-automation.md`](docs/development/quality-automation.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Quality Automation Guide
|
||||
|
||||
This document describes the automated quality gates, CI pipeline, and developer tooling
|
||||
for the CleverAgents project.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set up development environment (installs dependencies + pre-commit hooks)
|
||||
bash scripts/setup-dev.sh
|
||||
|
||||
# Run all quality checks
|
||||
nox
|
||||
|
||||
# Run specific checks
|
||||
nox -s lint # Ruff linting
|
||||
nox -s format # Ruff formatting
|
||||
nox -s typecheck # Pyright type checking
|
||||
nox -s unit_tests # Behave unit tests
|
||||
nox -s coverage_report # Coverage (must be >85%)
|
||||
nox -s security_scan # Bandit security scanning
|
||||
nox -s dead_code # Vulture dead code detection
|
||||
nox -s complexity # Radon complexity analysis
|
||||
nox -s pre_commit # Run all pre-commit hooks
|
||||
```
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
Pre-commit hooks run automatically on every `git commit`. They are configured in
|
||||
`.pre-commit-config.yaml`.
|
||||
|
||||
### Installed Hooks
|
||||
|
||||
| Hook | Tool | Purpose | Auto-fix |
|
||||
|------|------|---------|----------|
|
||||
| `no-commit-to-branch` | pre-commit-hooks | Prevents commits to `main` | No |
|
||||
| `check-yaml` | pre-commit-hooks | Validates YAML syntax | No |
|
||||
| `check-toml` | pre-commit-hooks | Validates TOML syntax | No |
|
||||
| `check-json` | pre-commit-hooks | Validates JSON syntax | No |
|
||||
| `check-merge-conflict` | pre-commit-hooks | Detects merge conflict markers | No |
|
||||
| `check-added-large-files` | pre-commit-hooks | Prevents files >500KB | No |
|
||||
| `end-of-file-fixer` | pre-commit-hooks | Ensures files end with newline | Yes |
|
||||
| `trailing-whitespace` | pre-commit-hooks | Removes trailing whitespace | Yes |
|
||||
| `debug-statements` | pre-commit-hooks | Detects `pdb`/`breakpoint` | No |
|
||||
| `ruff-format` | ruff | Code formatting | Yes |
|
||||
| `ruff` | ruff | Linting with safe auto-fixes | Yes |
|
||||
| `bandit` | bandit | Security scanning | No |
|
||||
| `vulture` | vulture | Dead code detection | No |
|
||||
| `pyright` | pyright | Type checking (src/ only) | No |
|
||||
| `semgrep-eval-exec` | semgrep | eval/exec detection (optional) | No |
|
||||
| `commitizen` | commitizen | Commit message format (commit-msg stage) | No |
|
||||
|
||||
### Running Hooks Manually
|
||||
|
||||
```bash
|
||||
# Run all hooks on all files
|
||||
pre-commit run --all-files
|
||||
|
||||
# Run a specific hook
|
||||
pre-commit run ruff-format --all-files
|
||||
pre-commit run bandit --all-files
|
||||
pre-commit run pyright --all-files
|
||||
|
||||
# Skip hooks for emergency (not recommended)
|
||||
git commit --no-verify -m "emergency: fix critical bug"
|
||||
```
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
The CI pipeline runs on **Forgejo Actions** (`.forgejo/workflows/ci.yml`).
|
||||
|
||||
### CI Jobs
|
||||
|
||||
| Job | Trigger | Purpose | Failure Impact |
|
||||
|-----|---------|---------|----------------|
|
||||
| `lint` | Push/PR | Ruff format + lint check | Blocks merge |
|
||||
| `typecheck` | Push/PR | Pyright type checking | Blocks merge |
|
||||
| `security` | Push/PR | Bandit + Vulture | Blocks merge |
|
||||
| `quality` | Push/PR | Radon complexity check | Blocks merge (grade F) |
|
||||
| `behave` | Push/PR | BDD tests (Python 3.11-3.13 matrix) | Blocks merge |
|
||||
| `coverage` | Push/PR | Coverage measurement | Blocks merge (<85%) |
|
||||
| `build` | Push/PR | Wheel build | Blocks release |
|
||||
| `docker` | Push/PR | Docker image build + test | Blocks deployment |
|
||||
| `helm` | Push/PR | Helm chart lint + template | Blocks deployment |
|
||||
|
||||
### Nightly Quality
|
||||
|
||||
A nightly workflow (`.forgejo/workflows/nightly-quality.yml`) runs at midnight UTC:
|
||||
|
||||
- Full lint, type check, security scan
|
||||
- Complete Behave test suite with coverage
|
||||
- Complexity and maintainability analysis
|
||||
- Quality trend data generation
|
||||
- Reports uploaded as artifacts (90-day retention)
|
||||
|
||||
## Security Scanning
|
||||
|
||||
### Bandit Configuration
|
||||
|
||||
Configured in `pyproject.toml` under `[tool.bandit]`:
|
||||
- Targets: `src/cleveragents` only
|
||||
- Severity threshold: MEDIUM (pre-commit), HIGH (CI fail gate)
|
||||
- Excludes: tests, features, build directories
|
||||
|
||||
### Semgrep Rules
|
||||
|
||||
Custom rules in `.semgrep.yml`:
|
||||
- `no-eval`: Blocks `eval()` usage
|
||||
- `no-exec`: Blocks `exec()` usage
|
||||
- `no-compile-exec`: Blocks `compile()` with exec mode
|
||||
- `no-os-system`: Warns on `os.system()` (use `subprocess.run`)
|
||||
- `no-pickle-loads`: Warns on `pickle.loads()` (use JSON)
|
||||
|
||||
**Note:** Semgrep is optional. Install with `pip install semgrep` for full scanning.
|
||||
|
||||
## Dead Code Detection
|
||||
|
||||
Vulture checks `src/cleveragents/` for unused code with 80% confidence threshold.
|
||||
|
||||
### False Positive Whitelist
|
||||
|
||||
Known false positives are listed in `vulture_whitelist.py`:
|
||||
- `exc_tb`: Required by `__exit__` protocol but not used in body
|
||||
- `build_data`: Required by mapping interface in legacy migrator
|
||||
|
||||
Add new entries when vulture flags intentionally unused code.
|
||||
|
||||
## Complexity Monitoring
|
||||
|
||||
Radon measures cyclomatic complexity:
|
||||
|
||||
| Grade | Complexity | Status |
|
||||
|-------|-----------|--------|
|
||||
| A | 1-5 | Excellent |
|
||||
| B | 6-10 | Good |
|
||||
| C | 11-15 | Moderate - review recommended |
|
||||
| D | 16-20 | Complex - refactoring recommended |
|
||||
| E | 21-30 | Very complex - must refactor |
|
||||
| F | 31+ | Extremely complex - CI fails |
|
||||
|
||||
Current project average: **A (3.56)**
|
||||
|
||||
## Quality Gate Script
|
||||
|
||||
`scripts/check-quality-gates.py` aggregates all quality checks:
|
||||
|
||||
```bash
|
||||
python scripts/check-quality-gates.py --coverage-min 85 --complexity-max F
|
||||
```
|
||||
|
||||
Gates checked:
|
||||
1. Coverage >= 85%
|
||||
2. Pyright: 0 type errors
|
||||
3. Bandit: 0 high-severity security issues
|
||||
4. Vulture: 0 dead code findings
|
||||
5. Radon: No grade-F complexity blocks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pre-commit hooks not running
|
||||
```bash
|
||||
pre-commit install
|
||||
pre-commit install --hook-type commit-msg
|
||||
```
|
||||
|
||||
### Pyright errors on clean code
|
||||
Check `pyrightconfig.json` for configuration. Pyright uses `strict` mode.
|
||||
|
||||
### Bandit false positive
|
||||
If bandit flags intentionally safe code, consider if there's a safer alternative first.
|
||||
Only as a last resort, add to `[tool.bandit]` `skips` in `pyproject.toml`.
|
||||
|
||||
### Vulture false positive
|
||||
Add the symbol name to `vulture_whitelist.py` with a comment explaining why.
|
||||
|
||||
### Coverage below 85%
|
||||
Run `nox -s coverage_report` to see which lines are uncovered, then add Behave scenarios.
|
||||
@@ -94,10 +94,8 @@ def after_scenario(context, scenario):
|
||||
# Run all cleanup handlers in reverse order
|
||||
if hasattr(context, "_cleanup_handlers"):
|
||||
for handler in reversed(context._cleanup_handlers):
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
handler()
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
context._cleanup_handlers = []
|
||||
|
||||
# Clean up any test directories
|
||||
|
||||
@@ -52,22 +52,21 @@ def _make_context_manager(
|
||||
@given("I have a saved context JSON file")
|
||||
def step_saved_context_json(context):
|
||||
context.load_context_data = {"global_context": {"session": "stored"}}
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
)
|
||||
json.dump(context.load_context_data, handle)
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
json.dump(context.load_context_data, handle)
|
||||
handle.flush()
|
||||
context.load_context_path = Path(handle.name)
|
||||
_register_cleanup(context, context.load_context_path)
|
||||
|
||||
|
||||
@given("I have an actor output file path")
|
||||
def step_actor_output_path(context):
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".txt", mode="w", encoding="utf-8"
|
||||
)
|
||||
handle.close()
|
||||
) as handle:
|
||||
pass
|
||||
context.output_path = Path(handle.name)
|
||||
_register_cleanup(context, context.output_path)
|
||||
|
||||
|
||||
@@ -64,12 +64,11 @@ def step_impl(context):
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 256,
|
||||
}
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
)
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -81,12 +80,11 @@ def step_impl(context):
|
||||
"model": "gpt-4o",
|
||||
"options": {"temperature": 0.4, "enabled": True},
|
||||
}
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
)
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -94,12 +92,11 @@ def step_impl(context):
|
||||
@given("I have an actor list config file")
|
||||
def step_impl(context):
|
||||
context.actor_config_data = [1, 2, 3]
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
)
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -110,10 +107,9 @@ def step_impl(context):
|
||||
"provider": "existing-provider",
|
||||
"model": "existing-model",
|
||||
}
|
||||
handle = tempfile.NamedTemporaryFile(delete=False, suffix=".yaml")
|
||||
handle.write(b"provider: existing-provider\nmodel: existing-model\n")
|
||||
handle.flush()
|
||||
handle.close()
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as handle:
|
||||
handle.write(b"provider: existing-provider\nmodel: existing-model\n")
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -125,12 +121,11 @@ def step_impl(context):
|
||||
"model": "gpt-4o-mini",
|
||||
"yaml_only": True,
|
||||
}
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".yaml", mode="w", encoding="utf-8"
|
||||
)
|
||||
handle.write("provider: openai\nmodel: gpt-4o-mini\nyaml_only: true\n")
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
handle.write("provider: openai\nmodel: gpt-4o-mini\nyaml_only: true\n")
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -142,12 +137,11 @@ def step_impl(context):
|
||||
"model": "graph-model",
|
||||
"graph": {"nodes": ["start"], "edges": []},
|
||||
}
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
)
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -156,12 +150,11 @@ def step_impl(context):
|
||||
def step_impl(context):
|
||||
context.actor_config_data = {}
|
||||
context.expected_error = "provider is required"
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".yaml", mode="w", encoding="utf-8"
|
||||
)
|
||||
handle.write("")
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
handle.write("")
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -173,12 +166,11 @@ def step_impl(context):
|
||||
"model": "gpt-4o-mini",
|
||||
"unsafe": True,
|
||||
}
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
)
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
@@ -521,12 +513,11 @@ def step_impl(context):
|
||||
@when("I run actor add with business rule violation")
|
||||
def step_impl(context):
|
||||
config_data = {"provider": "openai", "model": "gpt-4o"}
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
)
|
||||
json.dump(config_data, handle)
|
||||
handle.flush()
|
||||
handle.close()
|
||||
) as handle:
|
||||
json.dump(config_data, handle)
|
||||
handle.flush()
|
||||
config_path = Path(handle.name)
|
||||
_register_cleanup(context, config_path)
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ def step_check_adr_files(context):
|
||||
@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)}"
|
||||
)
|
||||
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")
|
||||
@@ -40,9 +40,9 @@ def step_verify_adr_format(context):
|
||||
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"
|
||||
)
|
||||
assert (
|
||||
"## Consequences" in content
|
||||
), f"{adr_file.name} missing Consequences section"
|
||||
|
||||
|
||||
@given('the source directory exists at "{path}"')
|
||||
@@ -141,9 +141,12 @@ def step_analyze_imports(context):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("cleveragents."):
|
||||
package_imports.add(alias.name.split(".")[1])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.startswith("cleveragents."):
|
||||
package_imports.add(node.module.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
|
||||
|
||||
@@ -202,9 +202,9 @@ def step_assert_attempts(context, count):
|
||||
@then('a CleverAgentsError should be raised with message "{message}"')
|
||||
def step_assert_cleveragents_error(context, message):
|
||||
exception = _get_recorded_exception(context)
|
||||
assert exception is not None, (
|
||||
"Expected CleverAgentsError but no exception was recorded"
|
||||
)
|
||||
assert (
|
||||
exception is not None
|
||||
), "Expected CleverAgentsError but no exception was recorded"
|
||||
assert isinstance(exception, CleverAgentsError)
|
||||
assert message in str(exception)
|
||||
|
||||
@@ -307,9 +307,9 @@ def step_assert_exit_code(context, code):
|
||||
actual = _capture_exit_code(context)
|
||||
if actual != code:
|
||||
output = _capture_output(context)
|
||||
assert actual == code, (
|
||||
f"Expected exit code {code}, got {actual}. CLI output:\n{output}"
|
||||
)
|
||||
assert (
|
||||
actual == code
|
||||
), f"Expected exit code {code}, got {actual}. CLI output:\n{output}"
|
||||
|
||||
|
||||
@then("the command should be aborted")
|
||||
@@ -332,7 +332,7 @@ def step_build_fail_then_succeed_with_changes(context, num_failures, changes):
|
||||
RuntimeError(f"Build failed attempt {i + 1}") for i in range(num_failures)
|
||||
]
|
||||
success_result = [object() for _ in range(changes)]
|
||||
context.plan_service_mock.build_plan.side_effect = failures + [success_result]
|
||||
context.plan_service_mock.build_plan.side_effect = [*failures, success_result]
|
||||
|
||||
|
||||
@given("the build will raise PlanError after one attempt")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Step definitions for CLI plan and context command testing."""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
@@ -67,7 +68,7 @@ def step_initialized_project(context):
|
||||
# Ensure an actor is available for mock provider flows
|
||||
container = get_container()
|
||||
actor_service = container.actor_service()
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
actor_service.upsert_actor(
|
||||
name="local/mock-default",
|
||||
provider="MockProvider",
|
||||
@@ -76,8 +77,6 @@ def step_initialized_project(context):
|
||||
is_built_in=True,
|
||||
unsafe=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@given('I have a file "{filename}" with content "{content}"')
|
||||
@@ -376,9 +375,9 @@ def step_file_in_context(context):
|
||||
@then('I should see "{text}" in the output')
|
||||
def step_see_text_in_output(context, text):
|
||||
"""Verify text appears in command output."""
|
||||
assert text in context.command_output, (
|
||||
f"Expected '{text}' in output: {context.command_output}"
|
||||
)
|
||||
assert (
|
||||
text in context.command_output
|
||||
), f"Expected '{text}' in output: {context.command_output}"
|
||||
|
||||
|
||||
@then("a new plan should be created")
|
||||
@@ -518,9 +517,9 @@ def step_error_mentions(context, text):
|
||||
"""Verify error message contains text."""
|
||||
# Check both stdout and stderr for error messages
|
||||
combined_output = (context.command_output or "") + (context.command_error or "")
|
||||
assert text.lower() in combined_output.lower(), (
|
||||
f"Expected '{text}' not found in: {combined_output}"
|
||||
)
|
||||
assert (
|
||||
text.lower() in combined_output.lower()
|
||||
), f"Expected '{text}' not found in: {combined_output}"
|
||||
|
||||
|
||||
@then('the output should mention "{text}"')
|
||||
|
||||
@@ -747,7 +747,7 @@ def _make_actor(
|
||||
model_name: str | None = None,
|
||||
):
|
||||
blob = config_blob or {"name": name}
|
||||
provider_value, model_value = (name.split("/", 1) + [""])[:2]
|
||||
provider_value, model_value = ([*name.split("/", 1), ""])[:2]
|
||||
return Actor(
|
||||
id=None,
|
||||
name=name,
|
||||
|
||||
@@ -71,7 +71,7 @@ def _ensure_temp_dir(context: Any) -> Path:
|
||||
@given("the context analysis agent module is importable")
|
||||
def step_module_importable(context: Any) -> None:
|
||||
ContextAnalysisAgent # noqa: B018 (import verification)
|
||||
ContextAnalysisState
|
||||
ContextAnalysisState # noqa: B018 (import verification)
|
||||
|
||||
|
||||
@given("I have a mock LLM provider configured for context analysis")
|
||||
|
||||
@@ -180,9 +180,9 @@ def step_convert_model_provider_to_string(context, member):
|
||||
@then("it should be a string type")
|
||||
def step_verify_string_type(context):
|
||||
"""Verify the value is a string."""
|
||||
assert isinstance(context.string_value, str), (
|
||||
f"Not a string: {type(context.string_value)}"
|
||||
)
|
||||
assert isinstance(
|
||||
context.string_value, str
|
||||
), f"Not a string: {type(context.string_value)}"
|
||||
|
||||
|
||||
@then('it should equal "{expected}"')
|
||||
@@ -213,8 +213,8 @@ def step_verify_valid_enum_value(context, value, enum_name):
|
||||
member = enum_class(value)
|
||||
assert member.value == value
|
||||
context.membership_checks[value] = True
|
||||
except ValueError:
|
||||
raise AssertionError(f"{value} is not a valid {enum_name} value")
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f"{value} is not a valid {enum_name} value") from exc
|
||||
|
||||
|
||||
@then('"{value}" should not be a valid {enum_name} value')
|
||||
@@ -240,18 +240,18 @@ def step_iterate_enum_values(context, enum_name):
|
||||
@then("I should get {count:d} enum members")
|
||||
def step_verify_enum_count(context, count):
|
||||
"""Verify the number of enum members."""
|
||||
assert context.member_count == count, (
|
||||
f"Expected {count} members, got {context.member_count}"
|
||||
)
|
||||
assert (
|
||||
context.member_count == count
|
||||
), f"Expected {count} members, got {context.member_count}"
|
||||
|
||||
|
||||
@then("each member should be a {enum_name} instance")
|
||||
def step_verify_enum_instances(context, enum_name):
|
||||
"""Verify each member is an instance of the enum."""
|
||||
for member in context.enum_members:
|
||||
assert isinstance(member, context.current_enum), (
|
||||
f"{member} is not a {enum_name} instance"
|
||||
)
|
||||
assert isinstance(
|
||||
member, context.current_enum
|
||||
), f"{member} is not a {enum_name} instance"
|
||||
|
||||
|
||||
# Enum comparison tests
|
||||
@@ -296,9 +296,9 @@ def step_access_enum_name(context, member):
|
||||
@then('the name should be "{expected}"')
|
||||
def step_verify_enum_name(context, expected):
|
||||
"""Verify the enum name attribute."""
|
||||
assert context.enum_name == expected, (
|
||||
f"Expected name {expected}, got {context.enum_name}"
|
||||
)
|
||||
assert (
|
||||
context.enum_name == expected
|
||||
), f"Expected name {expected}, got {context.enum_name}"
|
||||
|
||||
|
||||
@when("I access the value attribute of ModelPublisher.{member}")
|
||||
@@ -311,9 +311,9 @@ def step_access_enum_value(context, member):
|
||||
@then('the value should be "{expected}"')
|
||||
def step_verify_enum_value_attr(context, expected):
|
||||
"""Verify the enum value attribute."""
|
||||
assert context.enum_value_attr == expected, (
|
||||
f"Expected value {expected}, got {context.enum_value_attr}"
|
||||
)
|
||||
assert (
|
||||
context.enum_value_attr == expected
|
||||
), f"Expected value {expected}, got {context.enum_value_attr}"
|
||||
|
||||
|
||||
# Creating enum from value
|
||||
@@ -334,9 +334,9 @@ def step_verify_created_enum(context, member):
|
||||
"""Verify the created enum member."""
|
||||
expected = getattr(ModelProvider, member)
|
||||
assert context.creation_successful, "Enum creation failed"
|
||||
assert context.created_enum == expected, (
|
||||
f"Expected {expected}, got {context.created_enum}"
|
||||
)
|
||||
assert (
|
||||
context.created_enum == expected
|
||||
), f"Expected {expected}, got {context.created_enum}"
|
||||
assert context.created_enum is expected, "Enum instances should be identical"
|
||||
|
||||
|
||||
@@ -395,15 +395,15 @@ def step_get_string_representation(context):
|
||||
@then('repr should contain "{expected}"')
|
||||
def step_verify_repr_contains(context, expected):
|
||||
"""Verify repr contains expected string."""
|
||||
assert expected in context.repr_value, (
|
||||
f"'{expected}' not in repr: {context.repr_value}"
|
||||
)
|
||||
assert (
|
||||
expected in context.repr_value
|
||||
), f"'{expected}' not in repr: {context.repr_value}"
|
||||
|
||||
|
||||
@then('str should equal "{expected}"')
|
||||
def step_verify_str_equals(context, expected):
|
||||
"""Verify str equals expected."""
|
||||
# For str, Enum classes, str() returns the value, not the name
|
||||
assert context.enum_value.value == expected, (
|
||||
f"Expected str '{expected}', got '{context.enum_value.value}'"
|
||||
)
|
||||
assert (
|
||||
context.enum_value.value == expected
|
||||
), f"Expected str '{expected}', got '{context.enum_value.value}'"
|
||||
|
||||
@@ -68,8 +68,7 @@ def _setup_plan_generation_graph(context) -> MagicMock:
|
||||
events_copy = list(stream_events)
|
||||
|
||||
def _stream(*_args, **_kwargs):
|
||||
for event in events_copy:
|
||||
yield event
|
||||
yield from events_copy
|
||||
|
||||
mock_graph_instance.stream.side_effect = _stream
|
||||
|
||||
@@ -161,9 +160,9 @@ def step_assert_google_kwargs(context, kwargs_string):
|
||||
expected = _parse_kwargs_string(kwargs_string)
|
||||
for key, value in expected.items():
|
||||
assert key in call_kwargs, f"Expected {key} in ChatGoogleGenerativeAI kwargs"
|
||||
assert call_kwargs[key] == value, (
|
||||
f"Expected ChatGoogleGenerativeAI kwargs[{key!r}] to equal {value!r}"
|
||||
)
|
||||
assert (
|
||||
call_kwargs[key] == value
|
||||
), f"Expected ChatGoogleGenerativeAI kwargs[{key!r}] to equal {value!r}"
|
||||
|
||||
|
||||
@then(
|
||||
@@ -185,9 +184,9 @@ def step_assert_google_metadata(context, expected_name, expected_model):
|
||||
def step_assert_google_generated_change(context, count, file_path):
|
||||
response = getattr(context, "response", None)
|
||||
assert response is not None, "Provider response should exist"
|
||||
assert len(response.changes) == count, (
|
||||
f"Expected {count} changes, got {len(response.changes)}"
|
||||
)
|
||||
assert (
|
||||
len(response.changes) == count
|
||||
), f"Expected {count} changes, got {len(response.changes)}"
|
||||
assert any(
|
||||
getattr(change, "file_path", None) == file_path for change in response.changes
|
||||
), f"Expected change for {file_path}"
|
||||
|
||||
@@ -448,8 +448,7 @@ def step_provider_stream_failure_with_unknown_nodes(context):
|
||||
context.llm_instance.get_num_tokens.return_value = None
|
||||
|
||||
def _failing_stream():
|
||||
for event in streaming_events:
|
||||
yield event
|
||||
yield from streaming_events
|
||||
raise RuntimeError(failure_message)
|
||||
|
||||
with patch(
|
||||
@@ -644,9 +643,9 @@ def step_assert_response_contains_validation_error(context):
|
||||
assert context.response is not None
|
||||
assert context.response.changes, "Expected generated changes to be returned"
|
||||
assert context.response.changes[0].file_path == context.expected_change.file_path
|
||||
assert context.response.error_message == context.expected_validation_message, (
|
||||
"Validation failure message should be surfaced"
|
||||
)
|
||||
assert (
|
||||
context.response.error_message == context.expected_validation_message
|
||||
), "Validation failure message should be surfaced"
|
||||
assert context.response.model_used == "test-model"
|
||||
assert context.response.token_count == 0
|
||||
assert context.requested_models == ["test-model"]
|
||||
@@ -805,23 +804,23 @@ def step_assert_graph_failure_response(context):
|
||||
@then("the provider response should capture the nested retry failure")
|
||||
def step_assert_nested_retry_failure(context):
|
||||
assert context.response is not None, "Expected provider response to be populated"
|
||||
assert context.response.changes == [], (
|
||||
f"Expected no changes for nested failure, got {context.response.changes!r}"
|
||||
)
|
||||
assert context.response.error_message == context.failure_message, (
|
||||
f"Expected error message {context.failure_message!r}, got {context.response.error_message!r}"
|
||||
)
|
||||
assert (
|
||||
context.response.changes == []
|
||||
), f"Expected no changes for nested failure, got {context.response.changes!r}"
|
||||
assert (
|
||||
context.response.error_message == context.failure_message
|
||||
), f"Expected error message {context.failure_message!r}, got {context.response.error_message!r}"
|
||||
|
||||
|
||||
@then("the progress callback should end at 100 percent even on failure")
|
||||
def step_assert_failure_progress_updates(context):
|
||||
assert context.progress_updates, "Expected progress updates to be recorded"
|
||||
assert context.progress_updates[0] == 5, (
|
||||
f"Expected first progress update to be 5, got {context.progress_updates[0]!r}"
|
||||
)
|
||||
assert context.progress_updates[-1] == 100, (
|
||||
f"Expected final progress update to be 100, got {context.progress_updates[-1]!r}"
|
||||
)
|
||||
assert (
|
||||
context.progress_updates[0] == 5
|
||||
), f"Expected first progress update to be 5, got {context.progress_updates[0]!r}"
|
||||
assert (
|
||||
context.progress_updates[-1] == 100
|
||||
), f"Expected final progress update to be 100, got {context.progress_updates[-1]!r}"
|
||||
|
||||
|
||||
@then("the stream API should preserve non-dict payloads in events")
|
||||
|
||||
@@ -74,8 +74,7 @@ def _setup_plan_generation_graph(context) -> MagicMock:
|
||||
events_copy = list(stream_events)
|
||||
|
||||
def _stream(*_args, **_kwargs):
|
||||
for event in events_copy:
|
||||
yield event
|
||||
yield from events_copy
|
||||
|
||||
mock_graph_instance.stream.side_effect = _stream
|
||||
|
||||
@@ -235,9 +234,9 @@ def step_assert_chat_openai_constructor(context, api_key, model_id):
|
||||
assert call_kwargs["api_key"] == api_key
|
||||
assert call_kwargs["model"] == model_id
|
||||
|
||||
assert context.plan_generation_graph_call is not None, (
|
||||
"PlanGenerationGraph should receive the ChatOpenAI instance"
|
||||
)
|
||||
assert (
|
||||
context.plan_generation_graph_call is not None
|
||||
), "PlanGenerationGraph should receive the ChatOpenAI instance"
|
||||
_, graph_kwargs = context.plan_generation_graph_call
|
||||
assert graph_kwargs["llm"] is context.chat_openai_instance
|
||||
|
||||
@@ -263,9 +262,9 @@ def step_assert_chat_openai_kwargs(context, kwargs_string):
|
||||
expected_kwargs = _parse_kwargs_string(kwargs_string)
|
||||
for key, value in expected_kwargs.items():
|
||||
assert key in call_kwargs, f"Expected {key} in ChatOpenAI kwargs"
|
||||
assert call_kwargs[key] == value, (
|
||||
f"Expected ChatOpenAI kwargs[{key!r}] to equal {value!r}"
|
||||
)
|
||||
assert (
|
||||
call_kwargs[key] == value
|
||||
), f"Expected ChatOpenAI kwargs[{key!r}] to equal {value!r}"
|
||||
|
||||
|
||||
@then(
|
||||
@@ -299,9 +298,9 @@ def step_assert_no_changes(context):
|
||||
)
|
||||
def step_assert_generated_change(context, count, file_path):
|
||||
assert context.response is not None, "Provider response should exist"
|
||||
assert len(context.response.changes) == count, (
|
||||
f"Expected {count} changes, got {len(context.response.changes)}"
|
||||
)
|
||||
assert (
|
||||
len(context.response.changes) == count
|
||||
), f"Expected {count} changes, got {len(context.response.changes)}"
|
||||
assert any(
|
||||
getattr(change, "file_path", None) == file_path
|
||||
for change in context.response.changes
|
||||
|
||||
@@ -84,8 +84,7 @@ def _setup_plan_generation_graph(context) -> MagicMock:
|
||||
events_copy = list(stream_events)
|
||||
|
||||
def _stream(*_args, **_kwargs):
|
||||
for event in events_copy:
|
||||
yield event
|
||||
yield from events_copy
|
||||
|
||||
mock_graph_instance.stream.side_effect = _stream
|
||||
|
||||
@@ -211,9 +210,9 @@ def step_assert_openrouter_kwargs(context, kwargs_string):
|
||||
expected = _parse_kwargs_string(kwargs_string)
|
||||
for key, value in expected.items():
|
||||
assert key in call_kwargs, f"Expected {key} in ChatOpenAI kwargs"
|
||||
assert call_kwargs[key] == value, (
|
||||
f"Expected ChatOpenAI kwargs[{key!r}] to equal {value!r}"
|
||||
)
|
||||
assert (
|
||||
call_kwargs[key] == value
|
||||
), f"Expected ChatOpenAI kwargs[{key!r}] to equal {value!r}"
|
||||
|
||||
|
||||
@then(
|
||||
@@ -225,9 +224,9 @@ def step_assert_openrouter_headers(context, headers_string):
|
||||
_, call_kwargs = call
|
||||
expected = _parse_headers_string(headers_string)
|
||||
actual_headers = call_kwargs.get("default_headers")
|
||||
assert actual_headers == expected, (
|
||||
f"Expected default_headers {expected!r}, got {actual_headers!r}"
|
||||
)
|
||||
assert (
|
||||
actual_headers == expected
|
||||
), f"Expected default_headers {expected!r}, got {actual_headers!r}"
|
||||
|
||||
|
||||
@then(
|
||||
@@ -236,9 +235,9 @@ def step_assert_openrouter_headers(context, headers_string):
|
||||
def step_assert_openrouter_generated_change(context, count, file_path):
|
||||
response = getattr(context, "response", None)
|
||||
assert response is not None, "Provider response should exist"
|
||||
assert len(response.changes) == count, (
|
||||
f"Expected {count} changes, got {len(response.changes)}"
|
||||
)
|
||||
assert (
|
||||
len(response.changes) == count
|
||||
), f"Expected {count} changes, got {len(response.changes)}"
|
||||
assert any(
|
||||
getattr(change, "file_path", None) == file_path for change in response.changes
|
||||
), f"Expected change for {file_path}"
|
||||
|
||||
@@ -14,7 +14,8 @@ from rich.console import Console
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.application.services.plan_service import PlanService
|
||||
from cleveragents.cli.commands.plan import _tell_streaming, app as plan_app
|
||||
from cleveragents.cli.commands.plan import _tell_streaming
|
||||
from cleveragents.cli.commands.plan import app as plan_app
|
||||
|
||||
|
||||
class _StreamingPlanService:
|
||||
|
||||
@@ -328,18 +328,20 @@ def step_run_plan_apply_confirm(context):
|
||||
env = os.environ.copy()
|
||||
env["CLEVERAGENTS_TEST_FORCE_CONFIRMATION"] = "true"
|
||||
|
||||
with patch("cleveragents.application.container.get_container") as mock_container:
|
||||
with patch("cleveragents.cli.commands.plan.console", test_console):
|
||||
mock_plan_service = MagicMock(spec=PlanService)
|
||||
mock_plan_service.get_pending_changes.return_value = context.pending_changes
|
||||
mock_plan_service.apply_changes.return_value = len(context.pending_changes)
|
||||
mock_container.return_value.plan_service.return_value = mock_plan_service
|
||||
with (
|
||||
patch("cleveragents.application.container.get_container") as mock_container,
|
||||
patch("cleveragents.cli.commands.plan.console", test_console),
|
||||
):
|
||||
mock_plan_service = MagicMock(spec=PlanService)
|
||||
mock_plan_service.get_pending_changes.return_value = context.pending_changes
|
||||
mock_plan_service.apply_changes.return_value = len(context.pending_changes)
|
||||
mock_container.return_value.plan_service.return_value = mock_plan_service
|
||||
|
||||
result = runner.invoke(plan_app, ["apply"], input="y\n", env=env)
|
||||
result = runner.invoke(plan_app, ["apply"], input="y\n", env=env)
|
||||
|
||||
# Store both outputs for testing
|
||||
context.result = result
|
||||
context.console_output = string_buffer.getvalue()
|
||||
# Store both outputs for testing
|
||||
context.result = result
|
||||
context.console_output = string_buffer.getvalue()
|
||||
|
||||
|
||||
@when("I execute plan apply with auto-confirm")
|
||||
@@ -1126,9 +1128,9 @@ def step_assert_typer_abort(context):
|
||||
@then("the plan tell should execute successfully")
|
||||
def step_plan_tell_success(context):
|
||||
"""Assert plan tell executed successfully."""
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Exit code was {context.result.exit_code}, output: {context.result.output}"
|
||||
)
|
||||
assert (
|
||||
context.result.exit_code == 0
|
||||
), f"Exit code was {context.result.exit_code}, output: {context.result.output}"
|
||||
assert "Plan created" in context.result.output or "✓" in context.result.output
|
||||
|
||||
|
||||
@@ -1174,12 +1176,12 @@ def step_plan_tell_general_abort(context):
|
||||
@then("the plan tell streaming path should execute successfully")
|
||||
def step_plan_tell_streaming_success(context):
|
||||
"""Assert plan tell streaming path executed via asyncio."""
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Exit code was {context.result.exit_code}, output: {context.result.output}"
|
||||
)
|
||||
assert getattr(context, "stream_call_count", 0) == 1, (
|
||||
"Streaming coroutine was not awaited"
|
||||
)
|
||||
assert (
|
||||
context.result.exit_code == 0
|
||||
), f"Exit code was {context.result.exit_code}, output: {context.result.output}"
|
||||
assert (
|
||||
getattr(context, "stream_call_count", 0) == 1
|
||||
), "Streaming coroutine was not awaited"
|
||||
assert context.stream_call_args, "No streaming call arguments recorded"
|
||||
first_call = context.stream_call_args[0]
|
||||
assert first_call.args[1] == context.stream_prompt
|
||||
|
||||
@@ -643,11 +643,13 @@ def step_stub_provider_registry_lazy(context: Context) -> None:
|
||||
|
||||
# Restore mock flag after scenario
|
||||
cleanup_handlers.append(
|
||||
lambda: os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
lambda: (
|
||||
os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
|
||||
context.lazy_registry = lazy_registry
|
||||
@@ -1044,9 +1046,9 @@ def step_assert_lazy_registry_actor(
|
||||
registry = getattr(context, "lazy_registry", None)
|
||||
assert registry is not None, "Lazy registry stub was not configured"
|
||||
expected = {"provider": provider.lower(), "model": model.lower()}
|
||||
assert registry.requests == [expected], (
|
||||
"Lazy registry did not capture the actor request"
|
||||
)
|
||||
assert registry.requests == [
|
||||
expected
|
||||
], "Lazy registry did not capture the actor request"
|
||||
|
||||
resolution = getattr(context, "provider_resolution", None)
|
||||
assert resolution is not None, "Provider resolution result missing"
|
||||
@@ -1073,11 +1075,13 @@ def step_provider_registry_raises_for_actor(context: Context, message: str) -> N
|
||||
cleanup_handlers = []
|
||||
context._cleanup_handlers = cleanup_handlers
|
||||
cleanup_handlers.append(
|
||||
lambda: os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
lambda: (
|
||||
os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
|
||||
context.plan_service._provider_registry = _Registry()
|
||||
@@ -1112,11 +1116,13 @@ def step_provider_registry_factory_raises_for_actor(
|
||||
context._cleanup_handlers = cleanup_handlers
|
||||
cleanup_handlers.append(patcher.stop)
|
||||
cleanup_handlers.append(
|
||||
lambda: os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
lambda: (
|
||||
os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
|
||||
context.plan_service.ai_provider = None
|
||||
@@ -1139,11 +1145,13 @@ def step_provider_registry_returns_provider(
|
||||
cleanup_handlers = []
|
||||
context._cleanup_handlers = cleanup_handlers
|
||||
cleanup_handlers.append(
|
||||
lambda: os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
lambda: (
|
||||
os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
|
||||
class _Provider:
|
||||
@@ -1306,9 +1314,9 @@ def step_assert_stream_token_count(context: Context, expected: int) -> None:
|
||||
with context.unit_of_work.transaction() as ctx:
|
||||
plan = ctx.plans.get_current_for_project(context.test_project.id)
|
||||
assert plan is not None, "Plan was not persisted"
|
||||
assert plan.token_count == expected, (
|
||||
f"Expected token count {expected}, got {plan.token_count}"
|
||||
)
|
||||
assert (
|
||||
plan.token_count == expected
|
||||
), f"Expected token count {expected}, got {plan.token_count}"
|
||||
|
||||
|
||||
@then('the streaming failure events should include an error with message "{message}"')
|
||||
@@ -1325,9 +1333,9 @@ def step_assert_stream_failure_event(context: Context, message: str) -> None:
|
||||
failure_payload = payload
|
||||
break
|
||||
assert failure_payload is not None, "Expected a failure event in the stream"
|
||||
assert failure_payload.get("error") == message, (
|
||||
f"Expected failure message '{message}', got '{failure_payload.get('error')}'"
|
||||
)
|
||||
assert (
|
||||
failure_payload.get("error") == message
|
||||
), f"Expected failure message '{message}', got '{failure_payload.get('error')}'"
|
||||
|
||||
|
||||
@then(
|
||||
@@ -1339,9 +1347,9 @@ def step_assert_non_python_stream(context: Context) -> None:
|
||||
events = getattr(context, "stream_events", [])
|
||||
assert events, "Streaming events were not captured"
|
||||
end_event = events[-1]
|
||||
assert isinstance(end_event, dict) and "__end__" in end_event, (
|
||||
"Final streaming event did not include end payload"
|
||||
)
|
||||
assert (
|
||||
isinstance(end_event, dict) and "__end__" in end_event
|
||||
), "Final streaming event did not include end payload"
|
||||
payload = end_event["__end__"]
|
||||
changes = payload.get("changes") or []
|
||||
assert len(changes) == 1, "Expected exactly one streamed change"
|
||||
@@ -1387,12 +1395,12 @@ def step_assert_stub_provider_requests(
|
||||
requests = getattr(context, "stub_provider_requests", None)
|
||||
assert requests, "No provider override requests were recorded"
|
||||
last_request = requests[-1]
|
||||
assert last_request["provider"] == provider, (
|
||||
f"Expected provider '{provider}', got '{last_request['provider']}'"
|
||||
)
|
||||
assert last_request["model"] == model, (
|
||||
f"Expected model '{model}', got '{last_request['model']}'"
|
||||
)
|
||||
assert (
|
||||
last_request["provider"] == provider
|
||||
), f"Expected provider '{provider}', got '{last_request['provider']}'"
|
||||
assert (
|
||||
last_request["model"] == model
|
||||
), f"Expected model '{model}', got '{last_request['model']}'"
|
||||
|
||||
|
||||
@given("I have a plan service without providers configured")
|
||||
@@ -1405,11 +1413,13 @@ def step_plan_service_without_providers(context: Context) -> None:
|
||||
cleanup_handlers = []
|
||||
context._cleanup_handlers = cleanup_handlers
|
||||
cleanup_handlers.append(
|
||||
lambda: os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
lambda: (
|
||||
os.environ.__setitem__(
|
||||
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
if original_mock_flag is not None
|
||||
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
||||
)
|
||||
|
||||
if not hasattr(context, "unit_of_work"):
|
||||
@@ -1536,9 +1546,9 @@ def step_assert_validation_hint(context: Context) -> None:
|
||||
@given('the project has plans named "{first}" and "{second}"')
|
||||
def step_project_with_named_plans(context: Context, first: str, second: str) -> None:
|
||||
"""Ensure the project has specific plan names configured."""
|
||||
assert context.project.id is not None, (
|
||||
"Project must be persisted before adding plans"
|
||||
)
|
||||
assert (
|
||||
context.project.id is not None
|
||||
), "Project must be persisted before adding plans"
|
||||
with context.unit_of_work.transaction() as ctx:
|
||||
current_plan = ctx.plans.get_current_for_project(context.project.id)
|
||||
assert current_plan is not None, "Current plan is required for this step"
|
||||
@@ -1708,9 +1718,9 @@ def step_try_build_plan_no_current(context: Context) -> None:
|
||||
def step_check_plan_error(context: Context, message: str) -> None:
|
||||
"""Check that a PlanError was raised with specific message."""
|
||||
assert context.exception is not None, "No exception captured"
|
||||
assert isinstance(context.exception, PlanError), (
|
||||
f"Unexpected exception type {type(context.exception).__name__}"
|
||||
)
|
||||
assert isinstance(
|
||||
context.exception, PlanError
|
||||
), f"Unexpected exception type {type(context.exception).__name__}"
|
||||
actual_message = str(getattr(context.exception, "message", "")) or str(
|
||||
context.exception
|
||||
)
|
||||
@@ -1729,9 +1739,9 @@ def step_check_plan_error_contains(context: Context, message: str) -> None:
|
||||
def step_plan_error_details_include_provider_diagnostics(context: Context) -> None:
|
||||
"""Ensure provider diagnostics are present in the captured PlanError details."""
|
||||
assert context.exception is not None, "No exception captured"
|
||||
assert isinstance(context.exception, PlanError), (
|
||||
f"Expected PlanError but got {type(context.exception).__name__}"
|
||||
)
|
||||
assert isinstance(
|
||||
context.exception, PlanError
|
||||
), f"Expected PlanError but got {type(context.exception).__name__}"
|
||||
details = getattr(context.exception, "details", {}) or {}
|
||||
required_keys = {
|
||||
"configured_providers",
|
||||
@@ -1746,9 +1756,9 @@ def step_plan_error_details_include_provider_diagnostics(context: Context) -> No
|
||||
@given("I configured an auto-debug plan with a disappearing plan ID")
|
||||
def step_configure_auto_debug_disappearing_plan(context: Context) -> None:
|
||||
"""Set up a plan service whose current plan loses its ID mid-run."""
|
||||
assert hasattr(context, "temp_dir"), (
|
||||
"Temporary directory setup is required before configuring auto-debug"
|
||||
)
|
||||
assert hasattr(
|
||||
context, "temp_dir"
|
||||
), "Temporary directory setup is required before configuring auto-debug"
|
||||
|
||||
class FlickeringPlan:
|
||||
def __init__(self, plan_id: int) -> None:
|
||||
@@ -2296,18 +2306,18 @@ def step_check_nested_move(context: Context) -> None:
|
||||
old_path, new_path = context.nested_move_paths
|
||||
assert not old_path.exists(), "Original nested file still exists"
|
||||
assert new_path.exists(), "Nested destination file was not created"
|
||||
assert new_path.read_text() == "# Nested file to move", (
|
||||
"Nested destination content mismatch"
|
||||
)
|
||||
assert (
|
||||
new_path.read_text() == "# Nested file to move"
|
||||
), "Nested destination content mismatch"
|
||||
|
||||
|
||||
@given("the plan has a pending MOVE change to an absolute path")
|
||||
def step_add_absolute_move_change(context: Context) -> None:
|
||||
"""Add a MOVE change whose destination is an absolute path."""
|
||||
assert getattr(context, "project", None) is not None, "Project is required"
|
||||
assert getattr(context, "current_plan", None) is not None, (
|
||||
"Current plan is required"
|
||||
)
|
||||
assert (
|
||||
getattr(context, "current_plan", None) is not None
|
||||
), "Current plan is required"
|
||||
|
||||
source_path = context.project.path / "absolute_source.py"
|
||||
source_path.write_text("# Absolute move source")
|
||||
@@ -2430,12 +2440,12 @@ def step_check_plan_error_with_details(context: Context) -> None:
|
||||
with contextlib.suppress(builtins.BaseException):
|
||||
os.chmod(context.readonly_file, 0o644)
|
||||
|
||||
assert context.exception is not None, (
|
||||
f"Expected an exception but got result: {getattr(context, 'apply_result', 'none')}"
|
||||
)
|
||||
assert isinstance(context.exception, PlanError), (
|
||||
f"Expected PlanError but got {type(context.exception).__name__}: {context.exception}"
|
||||
)
|
||||
assert (
|
||||
context.exception is not None
|
||||
), f"Expected an exception but got result: {getattr(context, 'apply_result', 'none')}"
|
||||
assert isinstance(
|
||||
context.exception, PlanError
|
||||
), f"Expected PlanError but got {type(context.exception).__name__}: {context.exception}"
|
||||
assert "Failed to apply change" in str(context.exception.message)
|
||||
|
||||
|
||||
@@ -3285,9 +3295,9 @@ def step_assert_langsmith_builder_base_metadata(
|
||||
assert metadata.get("project_name") == project_name
|
||||
assert metadata.get("project_id") is None
|
||||
extra_metadata_keys = set(metadata.keys()) - {"project_id", "project_name"}
|
||||
assert not extra_metadata_keys, (
|
||||
f"Unexpected metadata keys present: {sorted(extra_metadata_keys)}"
|
||||
)
|
||||
assert (
|
||||
not extra_metadata_keys
|
||||
), f"Unexpected metadata keys present: {sorted(extra_metadata_keys)}"
|
||||
assert tags == ["service:plan"], f"Expected only base service tag, got {tags}"
|
||||
|
||||
|
||||
@@ -3374,16 +3384,18 @@ def step_run_auto_debug_with_attempts(context: Context, attempts: int) -> None:
|
||||
|
||||
try:
|
||||
if getattr(context, "force_auto_debug_failure", False):
|
||||
with patch.object(PlanService, "build_plan", side_effect=failing_build):
|
||||
with patch(agent_path) as agent_cls:
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.invoke.return_value = {
|
||||
"result": {"success": False, "fix": {}},
|
||||
}
|
||||
agent_cls.return_value = agent_instance
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=attempts
|
||||
)
|
||||
with (
|
||||
patch.object(PlanService, "build_plan", side_effect=failing_build),
|
||||
patch(agent_path) as agent_cls,
|
||||
):
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.invoke.return_value = {
|
||||
"result": {"success": False, "fix": {}},
|
||||
}
|
||||
agent_cls.return_value = agent_instance
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=attempts
|
||||
)
|
||||
else:
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=attempts
|
||||
@@ -3426,16 +3438,16 @@ def step_run_auto_debug_retry_success(context: Context) -> None:
|
||||
]
|
||||
|
||||
agent_path = "cleveragents.agents.graphs.auto_debug.AutoDebugAgent"
|
||||
with patch.object(PlanService, "build_plan", side_effect=build_plan_side_effect):
|
||||
with patch(agent_path) as agent_cls:
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.invoke.return_value = {
|
||||
"result": {"success": False, "fix": {}}
|
||||
}
|
||||
agent_cls.return_value = agent_instance
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=2
|
||||
)
|
||||
with (
|
||||
patch.object(PlanService, "build_plan", side_effect=build_plan_side_effect),
|
||||
patch(agent_path) as agent_cls,
|
||||
):
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.invoke.return_value = {"result": {"success": False, "fix": {}}}
|
||||
agent_cls.return_value = agent_instance
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=2
|
||||
)
|
||||
|
||||
with context.unit_of_work.transaction() as ctx:
|
||||
context.latest_debug_attempts = ctx.debug_attempts.get_for_plan(current_plan.id)
|
||||
@@ -3465,9 +3477,9 @@ def step_assert_auto_debug_retry_success(context: Context) -> None:
|
||||
|
||||
attempts = getattr(context, "latest_debug_attempts", []) or []
|
||||
assert attempts, "No debug attempts were recorded"
|
||||
assert any(attempt.success for attempt in attempts), (
|
||||
"Debug attempt was not marked successful"
|
||||
)
|
||||
assert any(
|
||||
attempt.success for attempt in attempts
|
||||
), "Debug attempt was not marked successful"
|
||||
|
||||
|
||||
@when("I try to stream plan generation without an AI provider")
|
||||
|
||||
@@ -529,7 +529,7 @@ def step_run_file_filter_show_no_project(context):
|
||||
def step_add_file_filters_with_defaults(context, include_glob, exclude_glob):
|
||||
"""Run the file-filter add command with include, exclude, and defaults."""
|
||||
runner = CliRunner()
|
||||
expected_excludes = [exclude_glob] + list(DEFAULT_IGNORE_PATTERNS)
|
||||
expected_excludes = [exclude_glob, *DEFAULT_IGNORE_PATTERNS]
|
||||
with patch("cleveragents.application.container.get_container") as mock_container:
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_current_project.return_value = context.mock_project
|
||||
@@ -673,6 +673,6 @@ def step_project_command_should_succeed(context):
|
||||
@then("the project command should exit with code {code:d}")
|
||||
def step_project_command_exit_code(context, code):
|
||||
"""Assert the project command exited with the expected code."""
|
||||
assert context.result.exit_code == code, (
|
||||
f"Expected exit code {code}, got {context.result.exit_code}. Output: {context.result.output}"
|
||||
)
|
||||
assert (
|
||||
context.result.exit_code == code
|
||||
), f"Expected exit code {code}, got {context.result.exit_code}. Output: {context.result.output}"
|
||||
|
||||
@@ -29,8 +29,8 @@ def step_registry_mixed(context):
|
||||
def __init__(self, name: str, sync: bool = False, async_capable: bool = False):
|
||||
self.name = name
|
||||
if sync:
|
||||
self.process_message_sync = (
|
||||
lambda content, metadata: f"{name}:{content}"
|
||||
self.process_message_sync = lambda content, metadata: (
|
||||
f"{name}:{content}"
|
||||
)
|
||||
if async_capable:
|
||||
self.process_message = lambda content, metadata: f"{name}:{content}"
|
||||
|
||||
@@ -121,27 +121,27 @@ def step_apply_async_exponential_retry(context, max_attempts):
|
||||
@then("the function should eventually succeed")
|
||||
def step_verify_function_succeeded(context):
|
||||
"""Verify function succeeded after retries."""
|
||||
assert context.retry_succeeded, (
|
||||
f"Function failed: {getattr(context, 'retry_error', 'Unknown error')}"
|
||||
)
|
||||
assert (
|
||||
context.retry_succeeded
|
||||
), f"Function failed: {getattr(context, 'retry_error', 'Unknown error')}"
|
||||
assert context.result == "success"
|
||||
|
||||
|
||||
@then("the async function should eventually succeed")
|
||||
def step_verify_async_function_succeeded(context):
|
||||
"""Verify async function succeeded after retries."""
|
||||
assert context.retry_succeeded, (
|
||||
f"Async function failed: {getattr(context, 'retry_error', 'Unknown error')}"
|
||||
)
|
||||
assert (
|
||||
context.retry_succeeded
|
||||
), f"Async function failed: {getattr(context, 'retry_error', 'Unknown error')}"
|
||||
assert context.result == "async success"
|
||||
|
||||
|
||||
@then("the function should be called {expected_calls:d} times")
|
||||
def step_verify_call_count(context, expected_calls):
|
||||
"""Verify function was called expected number of times."""
|
||||
assert context.call_count == expected_calls, (
|
||||
f"Expected {expected_calls} calls, got {context.call_count}"
|
||||
)
|
||||
assert (
|
||||
context.call_count == expected_calls
|
||||
), f"Expected {expected_calls} calls, got {context.call_count}"
|
||||
|
||||
|
||||
# Network retry pattern tests
|
||||
@@ -182,9 +182,9 @@ def step_verify_network_retry(context):
|
||||
@then("the function should be called {max_calls:d} times maximum")
|
||||
def step_verify_max_calls(context, max_calls):
|
||||
"""Verify function wasn't called more than max times."""
|
||||
assert context.call_count <= max_calls, (
|
||||
f"Function called {context.call_count} times, max is {max_calls}"
|
||||
)
|
||||
assert (
|
||||
context.call_count <= max_calls
|
||||
), f"Function called {context.call_count} times, max is {max_calls}"
|
||||
|
||||
|
||||
# Provider retry pattern tests
|
||||
@@ -261,18 +261,18 @@ def step_apply_file_retry(context):
|
||||
@then("the file operation should eventually succeed")
|
||||
def step_verify_file_retry_success(context):
|
||||
"""Verify the file operation eventually succeeds."""
|
||||
assert context.file_retry_succeeded, (
|
||||
f"File retry failed: {getattr(context, 'file_retry_error', 'Unknown error')}"
|
||||
)
|
||||
assert (
|
||||
context.file_retry_succeeded
|
||||
), f"File retry failed: {getattr(context, 'file_retry_error', 'Unknown error')}"
|
||||
assert context.file_result == "file success"
|
||||
|
||||
|
||||
@then("the file operation should be invoked {expected:d} times")
|
||||
def step_verify_file_retry_invocations(context, expected):
|
||||
"""Verify the file operation was invoked the expected number of times."""
|
||||
assert context.file_call_count == expected, (
|
||||
f"Expected {expected} calls, got {context.file_call_count}"
|
||||
)
|
||||
assert (
|
||||
context.file_call_count == expected
|
||||
), f"Expected {expected} calls, got {context.file_call_count}"
|
||||
|
||||
|
||||
# Circuit breaker tests
|
||||
@@ -327,7 +327,7 @@ def step_verify_immediate_failure(context):
|
||||
# Unexpected exception
|
||||
raise AssertionError(
|
||||
f"Expected CircuitBreakerOpen but got: {type(e).__name__}: {e}"
|
||||
)
|
||||
) from e
|
||||
|
||||
|
||||
@given("I have an open circuit breaker")
|
||||
@@ -496,9 +496,9 @@ def step_async_retry_context_manager_failure(context):
|
||||
@then("the async retry context manager should capture the exception")
|
||||
def step_verify_async_retry_context_manager_capture(context):
|
||||
"""Ensure the async retry context manager captured the exception."""
|
||||
assert context.async_retry_context_manager_error, (
|
||||
"Expected async runtime error to propagate"
|
||||
)
|
||||
assert (
|
||||
context.async_retry_context_manager_error
|
||||
), "Expected async runtime error to propagate"
|
||||
assert context.retry_context_errors_added_async >= 1
|
||||
assert isinstance(context.retry_context.errors[-1], Exception)
|
||||
|
||||
@@ -690,9 +690,9 @@ def step_apply_retry_on_result(context, max_attempts):
|
||||
@then("the decorated function should return the successful payload")
|
||||
def step_verify_retry_on_result_payload(context):
|
||||
"""Ensure retry_on_result eventually returns the non-retry payload."""
|
||||
assert context.retry_on_result_succeeded, (
|
||||
f"Retry on result failed: {context.retry_on_result_output}"
|
||||
)
|
||||
assert (
|
||||
context.retry_on_result_succeeded
|
||||
), f"Retry on result failed: {context.retry_on_result_output}"
|
||||
assert isinstance(context.retry_on_result_output, dict)
|
||||
assert context.retry_on_result_output.get("retry") is False
|
||||
|
||||
|
||||
@@ -429,7 +429,7 @@ def route_with_vision(context):
|
||||
|
||||
# Find the conditional node
|
||||
router_node = None
|
||||
for node_name, node_data in context.route_config.nodes.items():
|
||||
for _node_name, node_data in context.route_config.nodes.items():
|
||||
if node_data["type"] == "conditional":
|
||||
router_node = node_data
|
||||
break
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Step definitions for uncovered stream_router paths.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import rx
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
@@ -8,7 +8,7 @@ import tempfile
|
||||
import types
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import patch
|
||||
|
||||
from behave import given, then, when
|
||||
@@ -61,11 +61,11 @@ class _StubDocument:
|
||||
class RecordingFAISS:
|
||||
"""Test double that records FAISS usage."""
|
||||
|
||||
last_from_texts_args: dict[str, Any] | None = None
|
||||
last_built_instance: RecordingFAISS | None = None
|
||||
load_local_should_raise: bool = False
|
||||
load_local_calls: list[str] = []
|
||||
default_similarity_payload: list[tuple[_StubDocument, float]] = []
|
||||
last_from_texts_args: ClassVar[dict[str, Any] | None] = None
|
||||
last_built_instance: ClassVar[RecordingFAISS | None] = None
|
||||
load_local_should_raise: ClassVar[bool] = False
|
||||
load_local_calls: ClassVar[list[str]] = []
|
||||
default_similarity_payload: ClassVar[list[tuple[_StubDocument, float]]] = []
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[str] = []
|
||||
|
||||
+617
-473
File diff suppressed because it is too large
Load Diff
+90
-16
@@ -80,9 +80,18 @@ from typing import Dict, Iterable, List, Tuple
|
||||
|
||||
DEFAULT_FEATURE_ROOT = "features/"
|
||||
SUMMARY_PATTERNS = {
|
||||
"features": re.compile(r"(\\d+)\\s+feature(?:s)?\\s+passed,\\s+(\\d+)\\s+failed(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped"),
|
||||
"scenarios": re.compile(r"(\\d+)\\s+scenario(?:s)?\\s+passed,\\s+(\\d+)\\s+failed(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped"),
|
||||
"steps": re.compile(r"(\\d+)\\s+step(?:s)?\\s+passed,\\s+(\\d+)\\s+failed(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped"),
|
||||
"features": re.compile(
|
||||
r"(\\d+)\\s+feature(?:s)?\\s+passed,\\s+(\\d+)\\s+failed"
|
||||
r"(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped"
|
||||
),
|
||||
"scenarios": re.compile(
|
||||
r"(\\d+)\\s+scenario(?:s)?\\s+passed,\\s+(\\d+)\\s+failed"
|
||||
r"(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped"
|
||||
),
|
||||
"steps": re.compile(
|
||||
r"(\\d+)\\s+step(?:s)?\\s+passed,\\s+(\\d+)\\s+failed"
|
||||
r"(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped"
|
||||
),
|
||||
}
|
||||
DURATION_PATTERN = re.compile(r"Took\\s+(?:(\\d+)m\\s*)?([\\d\\.]+)s")
|
||||
|
||||
@@ -129,7 +138,9 @@ def _parse_summary(text: str) -> Dict[str, Dict[str, int] | float]:
|
||||
|
||||
|
||||
|
||||
def _merge_summaries(summaries: List[Dict[str, Dict[str, int] | float]]) -> Dict[str, Dict[str, int] | float]:
|
||||
def _merge_summaries(
|
||||
summaries: List[Dict[str, Dict[str, int] | float]],
|
||||
) -> Dict[str, Dict[str, int] | float]:
|
||||
total = _empty_summary()
|
||||
for summary in summaries:
|
||||
for bucket in ("features", "scenarios", "steps"):
|
||||
@@ -149,21 +160,20 @@ def _format_duration(seconds: float) -> str:
|
||||
|
||||
def _print_overall_summary(total: Dict[str, Dict[str, int] | float]) -> None:
|
||||
print("\\nOverall summary:")
|
||||
print(
|
||||
f"{total['features']['passed']} features passed, {total['features']['failed']} failed, {total['features']['errors']} errored, {total['features']['skipped']} skipped"
|
||||
)
|
||||
print(
|
||||
f"{total['scenarios']['passed']} scenarios passed, {total['scenarios']['failed']} failed, {total['scenarios']['errors']} errored, {total['scenarios']['skipped']} skipped"
|
||||
)
|
||||
print(
|
||||
f"{total['steps']['passed']} steps passed, {total['steps']['failed']} failed, {total['steps']['errors']} errored, {total['steps']['skipped']} skipped"
|
||||
)
|
||||
for bucket in ("features", "scenarios", "steps"):
|
||||
b = total[bucket]
|
||||
print(
|
||||
f"{b['passed']} {bucket} passed, {b['failed']} failed, "
|
||||
f"{b['errors']} errored, {b['skipped']} skipped"
|
||||
)
|
||||
if total["duration"]:
|
||||
print(f"Took {_format_duration(float(total['duration']))} (sum across workers)")
|
||||
|
||||
|
||||
|
||||
def _run_feature(base_args: List[str], feature: str) -> Tuple[int, str, str, str, Dict[str, Dict[str, int] | float]]:
|
||||
def _run_feature(
|
||||
base_args: List[str], feature: str
|
||||
) -> Tuple[int, str, str, str, Dict[str, Dict[str, int] | float]]:
|
||||
proc = subprocess.run([*base_args, feature], capture_output=True, text=True)
|
||||
combined_output = (proc.stdout or "") + "\\n" + (proc.stderr or "")
|
||||
summary = _parse_summary(combined_output)
|
||||
@@ -204,7 +214,9 @@ def main(argv: List[str] | None = None) -> None:
|
||||
feature_args, other_args = _extract_features_and_args(remaining)
|
||||
|
||||
if any(flag in remaining for flag in ("-h", "--help", "--version")):
|
||||
code = subprocess.run([sys.executable, "-m", "behave", *other_args, *feature_args]).returncode
|
||||
code = subprocess.run(
|
||||
[sys.executable, "-m", "behave", *other_args, *feature_args]
|
||||
).returncode
|
||||
sys.exit(code)
|
||||
|
||||
feature_paths = _iter_features(feature_args)
|
||||
@@ -217,7 +229,10 @@ def main(argv: List[str] | None = None) -> None:
|
||||
summaries: List[Dict[str, Dict[str, int] | float]] = []
|
||||
failures: List[Tuple[int, str]] = []
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=processes) as executor:
|
||||
futures = [executor.submit(_run_feature, base_args, feature) for feature in feature_paths]
|
||||
futures = [
|
||||
executor.submit(_run_feature, base_args, feature)
|
||||
for feature in feature_paths
|
||||
]
|
||||
for fut in concurrent.futures.as_completed(futures):
|
||||
code, feature, stdout, stderr, summary = fut.result()
|
||||
if stdout:
|
||||
@@ -575,6 +590,65 @@ def discovery_integration(session: nox.Session):
|
||||
)
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def pre_commit(session: nox.Session):
|
||||
"""Run all pre-commit hooks on all files."""
|
||||
session.install("-e", ".[dev]")
|
||||
session.run("pre-commit", "run", "--all-files", *session.posargs)
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def security_scan(session: nox.Session):
|
||||
"""Run security scanning tools (bandit) on production code."""
|
||||
session.install("-e", ".[dev]")
|
||||
session.run(
|
||||
"bandit",
|
||||
"-c",
|
||||
"pyproject.toml",
|
||||
"-r",
|
||||
"src/cleveragents",
|
||||
"--severity-level",
|
||||
"medium",
|
||||
)
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def dead_code(session: nox.Session):
|
||||
"""Run vulture to detect dead code."""
|
||||
session.install("-e", ".[dev]")
|
||||
session.run(
|
||||
"vulture",
|
||||
"src/cleveragents",
|
||||
"vulture_whitelist.py",
|
||||
"--min-confidence",
|
||||
"80",
|
||||
"--exclude",
|
||||
"src/cleveragents/discovery",
|
||||
)
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def complexity(session: nox.Session):
|
||||
"""Check code complexity using radon."""
|
||||
session.install("-e", ".[dev]")
|
||||
session.run(
|
||||
"radon",
|
||||
"cc",
|
||||
"src/cleveragents",
|
||||
"--min",
|
||||
"C",
|
||||
"--show-complexity",
|
||||
"--total-average",
|
||||
)
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def adr_compliance(session: nox.Session):
|
||||
"""Check code compliance with Architecture Decision Records."""
|
||||
session.install("-e", ".[dev]")
|
||||
session.run("python", "scripts/check-adr-compliance.py")
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def benchmark(session: nox.Session):
|
||||
"""Run Airspeed Velocity benchmarks and publish results."""
|
||||
|
||||
+36
-1
@@ -56,6 +56,14 @@ dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
# Pre-commit hooks
|
||||
"pre-commit>=3.6.0",
|
||||
# Security scanning
|
||||
"bandit[toml]>=1.7.5",
|
||||
# Dead code detection
|
||||
"vulture>=2.10",
|
||||
# Complexity metrics
|
||||
"radon>=6.0.1",
|
||||
]
|
||||
tests = [
|
||||
"coverage>=7.11.0",
|
||||
@@ -88,7 +96,7 @@ include = ["src/cleveragents/py.typed"]
|
||||
line-length = 88
|
||||
target-version = "py313" # Target Python 3.13
|
||||
src = ["src", "tests", "benchmarks"]
|
||||
extend-exclude = ["docs/reference/contracts/stubs/*.py"]
|
||||
extend-exclude = ["docs/reference/contracts/stubs/*.py", "scripts/*.sh"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "B", "UP", "I", "SIM", "RUF"]
|
||||
@@ -96,6 +104,10 @@ ignore = []
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"]
|
||||
# Behave step files: F811 = redefined step_impl (Behave pattern), E501 = long step decorator strings
|
||||
"features/steps/*.py" = ["F811", "E501"]
|
||||
"features/mocks/*.py" = ["E501"]
|
||||
"features/environment.py" = ["E501"]
|
||||
|
||||
[tool.ruff.format]
|
||||
# Use double quotes for strings
|
||||
@@ -166,5 +178,28 @@ directory = "build/htmlcov"
|
||||
[tool.coverage.xml]
|
||||
output = "build/coverage.xml"
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["src/cleveragents"]
|
||||
exclude_dirs = [
|
||||
"tests",
|
||||
"features",
|
||||
".nox",
|
||||
"build",
|
||||
"dist",
|
||||
"docs",
|
||||
"src/cleveragents/discovery",
|
||||
]
|
||||
# Severity level: LOW, MEDIUM, HIGH
|
||||
severity = "MEDIUM"
|
||||
# Confidence level: LOW, MEDIUM, HIGH
|
||||
confidence = "MEDIUM"
|
||||
# Skip specific tests if needed
|
||||
skips = []
|
||||
|
||||
[tool.vulture]
|
||||
min_confidence = 80
|
||||
paths = ["src/cleveragents"]
|
||||
exclude = ["src/cleveragents/discovery"]
|
||||
|
||||
[tool.hatch]
|
||||
# Hatch build configuration
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ADR compliance checker for CleverAgents.
|
||||
|
||||
Verifies that code follows the architectural decisions recorded in ADRs.
|
||||
|
||||
ADR-002: Asyncio Concurrency Model
|
||||
- Async functions should use 'async def', not threading
|
||||
- No direct thread usage in application layer
|
||||
|
||||
ADR-003: Dependency Injection Framework
|
||||
- Services should receive dependencies via __init__
|
||||
- No direct instantiation of infrastructure in application layer
|
||||
|
||||
ADR-007: Repository Pattern for Persistence
|
||||
- Database access only through repository classes
|
||||
- No direct SQLAlchemy session usage in services
|
||||
|
||||
Usage:
|
||||
python scripts/check-adr-compliance.py
|
||||
"""
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_adr002_async_model(src_dir: Path) -> list[str]:
|
||||
"""Check ADR-002: Asyncio Concurrency Model.
|
||||
|
||||
Verifies no direct threading usage in application code.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
app_dir = src_dir / "application"
|
||||
if not app_dir.exists():
|
||||
return violations
|
||||
|
||||
for py_file in app_dir.rglob("*.py"):
|
||||
content = py_file.read_text()
|
||||
if "import threading" in content or "from threading import" in content:
|
||||
violations.append(
|
||||
f"ADR-002 violation: {py_file.relative_to(src_dir)} "
|
||||
"imports threading directly. Use asyncio instead."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_adr003_dependency_injection(src_dir: Path) -> list[str]:
|
||||
"""Check ADR-003: Dependency Injection Framework.
|
||||
|
||||
Verifies services use constructor injection.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
services_dir = src_dir / "application" / "services"
|
||||
if not services_dir.exists():
|
||||
return violations
|
||||
|
||||
for py_file in services_dir.rglob("*.py"):
|
||||
if py_file.name.startswith("__"):
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(py_file.read_text())
|
||||
except SyntaxError:
|
||||
continue
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
# Check if class has __init__ with parameters (DI)
|
||||
has_init = False
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.FunctionDef) and item.name == "__init__":
|
||||
has_init = True
|
||||
# Check for at least one parameter beyond self
|
||||
if len(item.args.args) < 2:
|
||||
violations.append(
|
||||
f"ADR-003 note: {py_file.relative_to(src_dir)}:"
|
||||
f"{node.lineno} class {node.name} has __init__ "
|
||||
"with no injected dependencies"
|
||||
)
|
||||
if not has_init and node.body:
|
||||
# Services without __init__ may be okay if they're utility classes
|
||||
pass
|
||||
return violations
|
||||
|
||||
|
||||
def check_adr007_repository_pattern(src_dir: Path) -> list[str]:
|
||||
"""Check ADR-007: Repository Pattern for Persistence.
|
||||
|
||||
Verifies no direct SQLAlchemy session usage in service layer.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
services_dir = src_dir / "application" / "services"
|
||||
if not services_dir.exists():
|
||||
return violations
|
||||
|
||||
for py_file in services_dir.rglob("*.py"):
|
||||
content = py_file.read_text()
|
||||
# Check for direct session imports
|
||||
if "from sqlalchemy" in content:
|
||||
violations.append(
|
||||
f"ADR-007 violation: {py_file.relative_to(src_dir)} "
|
||||
"imports SQLAlchemy directly. Use repository pattern instead."
|
||||
)
|
||||
if "session.query" in content or "session.execute" in content:
|
||||
violations.append(
|
||||
f"ADR-007 violation: {py_file.relative_to(src_dir)} "
|
||||
"uses session directly. Access data through repositories."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run all ADR compliance checks."""
|
||||
src_dir = Path("src/cleveragents")
|
||||
if not src_dir.exists():
|
||||
print("Error: src/cleveragents directory not found")
|
||||
return 1
|
||||
|
||||
all_violations: list[str] = []
|
||||
|
||||
print("Checking ADR-002: Asyncio Concurrency Model...")
|
||||
all_violations.extend(check_adr002_async_model(src_dir))
|
||||
|
||||
print("Checking ADR-003: Dependency Injection Framework...")
|
||||
all_violations.extend(check_adr003_dependency_injection(src_dir))
|
||||
|
||||
print("Checking ADR-007: Repository Pattern for Persistence...")
|
||||
all_violations.extend(check_adr007_repository_pattern(src_dir))
|
||||
|
||||
if all_violations:
|
||||
print(f"\nFound {len(all_violations)} ADR compliance issue(s):")
|
||||
for violation in all_violations:
|
||||
print(f" - {violation}")
|
||||
return 1
|
||||
|
||||
print("\nAll ADR compliance checks passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quality gate checker for CleverAgents CI pipeline.
|
||||
|
||||
Aggregates results from multiple quality tools and produces a summary
|
||||
report. Returns non-zero exit code if any critical gate fails.
|
||||
|
||||
Usage:
|
||||
python scripts/check-quality-gates.py [--coverage-min 85] [--complexity-max 10]
|
||||
|
||||
Gates checked:
|
||||
1. Coverage >= threshold (default 85%)
|
||||
2. No type errors (pyright clean)
|
||||
3. No high-severity security issues (bandit)
|
||||
4. No dead code (vulture clean)
|
||||
5. No extreme complexity (radon grade F)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_coverage(min_coverage: int) -> tuple[bool, str]:
|
||||
"""Check test coverage meets minimum threshold."""
|
||||
result = subprocess.run(
|
||||
["coverage", "report", "--format=total"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, f"Coverage command failed: {result.stderr.strip()}"
|
||||
|
||||
try:
|
||||
total = float(result.stdout.strip())
|
||||
except ValueError:
|
||||
return False, f"Could not parse coverage output: {result.stdout.strip()}"
|
||||
|
||||
passed = total >= min_coverage
|
||||
msg = f"Coverage: {total:.1f}% (minimum: {min_coverage}%)"
|
||||
return passed, msg
|
||||
|
||||
|
||||
def check_typecheck() -> tuple[bool, str]:
|
||||
"""Check pyright type checking passes."""
|
||||
result = subprocess.run(
|
||||
["pyright", "--outputjson"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
error_count = data.get("summary", {}).get("errorCount", -1)
|
||||
if error_count == 0:
|
||||
return True, "Type checking: 0 errors"
|
||||
return False, f"Type checking: {error_count} errors"
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# Fallback to exit code
|
||||
if result.returncode == 0:
|
||||
return True, "Type checking: passed"
|
||||
return False, f"Type checking: failed (exit code {result.returncode})"
|
||||
|
||||
|
||||
def check_security() -> tuple[bool, str]:
|
||||
"""Check bandit finds no high-severity issues."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bandit",
|
||||
"-c",
|
||||
"pyproject.toml",
|
||||
"-r",
|
||||
"src/cleveragents",
|
||||
"--severity-level",
|
||||
"high",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
high_issues = len(data.get("results", []))
|
||||
if high_issues == 0:
|
||||
return True, "Security: 0 high-severity issues"
|
||||
return False, f"Security: {high_issues} high-severity issues"
|
||||
except json.JSONDecodeError:
|
||||
if result.returncode == 0:
|
||||
return True, "Security: passed"
|
||||
return False, f"Security: failed (exit code {result.returncode})"
|
||||
|
||||
|
||||
def check_dead_code() -> tuple[bool, str]:
|
||||
"""Check vulture finds no dead code."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"vulture",
|
||||
"src/cleveragents",
|
||||
"vulture_whitelist.py",
|
||||
"--min-confidence",
|
||||
"80",
|
||||
"--exclude",
|
||||
"src/cleveragents/discovery",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, "Dead code: none detected"
|
||||
lines = result.stdout.strip().split("\n")
|
||||
return False, f"Dead code: {len(lines)} issue(s) found"
|
||||
|
||||
|
||||
def check_complexity(max_grade: str = "E") -> tuple[bool, str]:
|
||||
"""Check no functions exceed maximum complexity grade."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"radon",
|
||||
"cc",
|
||||
"src/cleveragents",
|
||||
"--min",
|
||||
max_grade,
|
||||
"--show-complexity",
|
||||
"--json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
total_blocks = sum(len(blocks) for blocks in data.values())
|
||||
if total_blocks == 0:
|
||||
return True, f"Complexity: no blocks with grade >= {max_grade}"
|
||||
return False, f"Complexity: {total_blocks} block(s) with grade >= {max_grade}"
|
||||
except json.JSONDecodeError:
|
||||
return True, "Complexity: passed (no extreme complexity)"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run all quality gates and report results."""
|
||||
parser = argparse.ArgumentParser(description="Check quality gates")
|
||||
parser.add_argument(
|
||||
"--coverage-min", type=int, default=85, help="Minimum coverage percentage"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complexity-max",
|
||||
type=str,
|
||||
default="F",
|
||||
help="Maximum allowed complexity grade (F = fail only on worst)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Ensure build directory exists
|
||||
Path("build").mkdir(exist_ok=True)
|
||||
|
||||
gates: list[tuple[str, tuple[bool, str]]] = [
|
||||
("Coverage", check_coverage(args.coverage_min)),
|
||||
("Type Check", check_typecheck()),
|
||||
("Security", check_security()),
|
||||
("Dead Code", check_dead_code()),
|
||||
("Complexity", check_complexity(args.complexity_max)),
|
||||
]
|
||||
|
||||
print("=" * 60)
|
||||
print("QUALITY GATE REPORT")
|
||||
print("=" * 60)
|
||||
|
||||
all_passed = True
|
||||
for name, (passed, message) in gates:
|
||||
icon = "[+]" if passed else "[X]"
|
||||
print(f" {icon} {name}: {message}")
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
print("=" * 60)
|
||||
if all_passed:
|
||||
print("All quality gates PASSED")
|
||||
else:
|
||||
print("Some quality gates FAILED")
|
||||
print("=" * 60)
|
||||
|
||||
return 0 if all_passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Run semgrep if installed, skip gracefully if not
|
||||
if command -v semgrep >/dev/null 2>&1; then
|
||||
semgrep --config=.semgrep.yml --error --quiet src/
|
||||
else
|
||||
echo "semgrep not installed, skipping (install with: pip install semgrep)"
|
||||
fi
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Developer setup script for CleverAgents
|
||||
# Usage: bash scripts/setup-dev.sh
|
||||
#
|
||||
# This script sets up the development environment including:
|
||||
# - Installing all dependencies (dev, tests, docs extras)
|
||||
# - Installing pre-commit hooks
|
||||
# - Verifying tool installations
|
||||
# - Running an initial quality check
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() { echo -e "${BOLD}${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${BOLD}${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${BOLD}${RED}[ERROR]${NC} $*"; }
|
||||
|
||||
# Check Python version
|
||||
info "Checking Python version..."
|
||||
PYTHON_VERSION=$(python3 --version 2>&1 | grep -oP '\d+\.\d+')
|
||||
REQUIRED_VERSION="3.13"
|
||||
if [ "$(printf '%s\n' "$REQUIRED_VERSION" "$PYTHON_VERSION" | sort -V | head -n1)" != "$REQUIRED_VERSION" ]; then
|
||||
error "Python >= $REQUIRED_VERSION is required, found $PYTHON_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
info "Python $PYTHON_VERSION detected"
|
||||
|
||||
# Install the project with all development extras
|
||||
info "Installing project with dev, tests, and docs extras..."
|
||||
pip install -e ".[dev,tests,docs]" --quiet
|
||||
|
||||
# Install pre-commit hooks
|
||||
info "Installing pre-commit hooks..."
|
||||
pre-commit install
|
||||
pre-commit install --hook-type commit-msg
|
||||
info "Pre-commit hooks installed (pre-commit and commit-msg stages)"
|
||||
|
||||
# Verify key tools are available
|
||||
info "Verifying tool installations..."
|
||||
TOOLS_OK=true
|
||||
|
||||
for tool in ruff pyright bandit vulture radon pre-commit behave; do
|
||||
if command -v "$tool" > /dev/null 2>&1; then
|
||||
VERSION=$("$tool" --version 2>&1 | head -1)
|
||||
info " $tool: $VERSION"
|
||||
else
|
||||
warn " $tool: NOT FOUND (check PATH or installation)"
|
||||
TOOLS_OK=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$TOOLS_OK" = false ]; then
|
||||
warn "Some tools were not found. They may be installed in a location not on your PATH."
|
||||
warn "Try: export PATH=\"\$HOME/.local/bin:\$PATH\""
|
||||
fi
|
||||
|
||||
# Run a quick quality check
|
||||
info "Running initial quality check..."
|
||||
echo ""
|
||||
info "--- Ruff Format Check ---"
|
||||
ruff format --check . 2>&1 || warn "Some files need formatting. Run: ruff format ."
|
||||
|
||||
echo ""
|
||||
info "--- Ruff Lint Check ---"
|
||||
ruff check . 2>&1 || warn "Some lint issues found. Run: ruff check --fix ."
|
||||
|
||||
echo ""
|
||||
info "--- Pyright Type Check ---"
|
||||
pyright 2>&1 || warn "Type checking found issues."
|
||||
|
||||
echo ""
|
||||
info "Development environment setup complete."
|
||||
info ""
|
||||
info "Available commands:"
|
||||
info " nox - Run all quality checks and tests"
|
||||
info " nox -s unit_tests - Run Behave unit tests"
|
||||
info " nox -s integration_tests - Run Robot integration tests"
|
||||
info " nox -s coverage_report - Run tests with coverage"
|
||||
info " nox -s lint - Run linter"
|
||||
info " nox -s format - Format code"
|
||||
info " nox -s typecheck - Run type checker"
|
||||
info " pre-commit run --all-files - Run all pre-commit hooks"
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,7 +18,7 @@ class YAMLTemplateEngine:
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Configure Jinja2 for YAML-friendly output
|
||||
self.env = Environment(
|
||||
self.env = SandboxedEnvironment(
|
||||
block_start_string="{%",
|
||||
block_end_string="%}",
|
||||
variable_start_string="{{",
|
||||
|
||||
@@ -919,7 +919,8 @@ class ContextService:
|
||||
return []
|
||||
|
||||
service = self._vector_store_service
|
||||
assert service is not None
|
||||
if service is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
return service.search(
|
||||
|
||||
@@ -12,7 +12,7 @@ contexts, changes) across conversation sessions.
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import Any, cast
|
||||
|
||||
from langchain_community.chat_message_histories import SQLChatMessageHistory
|
||||
@@ -24,7 +24,7 @@ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EntityType(str, Enum):
|
||||
class EntityType(StrEnum):
|
||||
"""Types of entities that can be tracked in memory."""
|
||||
|
||||
PROJECT = "project"
|
||||
@@ -340,10 +340,13 @@ class ConversationBufferMemoryAdapter:
|
||||
if isinstance(value, BaseMessage):
|
||||
self._message_history.add_message(value)
|
||||
return
|
||||
if isinstance(value, (str, bytes, bytearray)):
|
||||
if isinstance(value, str | bytes | bytearray):
|
||||
self._message_history.add_user_message(str(value))
|
||||
return
|
||||
assert isinstance(value, Sequence)
|
||||
if not isinstance(value, Sequence):
|
||||
raise TypeError(
|
||||
f"Expected Sequence for user message, got {type(value).__name__}"
|
||||
)
|
||||
for item in value:
|
||||
_store_user(item)
|
||||
|
||||
@@ -353,10 +356,13 @@ class ConversationBufferMemoryAdapter:
|
||||
if isinstance(value, BaseMessage):
|
||||
self._message_history.add_message(value)
|
||||
return
|
||||
if isinstance(value, (str, bytes, bytearray)):
|
||||
if isinstance(value, str | bytes | bytearray):
|
||||
self._message_history.add_ai_message(str(value))
|
||||
return
|
||||
assert isinstance(value, Sequence)
|
||||
if not isinstance(value, Sequence):
|
||||
raise TypeError(
|
||||
f"Expected Sequence for AI message, got {type(value).__name__}"
|
||||
)
|
||||
for item in value:
|
||||
_store_ai(item)
|
||||
|
||||
|
||||
@@ -1309,7 +1309,7 @@ class PlanService:
|
||||
return {
|
||||
"changes": [],
|
||||
"model_used": "",
|
||||
"token_count": 0,
|
||||
"token_count": 0, # nosec B105
|
||||
"error_message": str(payload),
|
||||
}
|
||||
|
||||
|
||||
@@ -672,7 +672,8 @@ def context_delete(
|
||||
typer.echo(f"Deleted {count} context(s).")
|
||||
else:
|
||||
# Delete a single context by name
|
||||
assert name is not None
|
||||
if name is None:
|
||||
raise typer.BadParameter("Context name is required for deletion")
|
||||
target = ctx_base / name
|
||||
|
||||
if not target.exists():
|
||||
|
||||
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_ENV"),
|
||||
)
|
||||
server_host: str = Field(
|
||||
default="0.0.0.0",
|
||||
default="0.0.0.0", # nosec B104
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_HOST"),
|
||||
)
|
||||
server_port: int = Field(
|
||||
|
||||
@@ -464,7 +464,8 @@ class RetryContext:
|
||||
with attempt:
|
||||
self.attempt_count = attempt.retry_state.attempt_number
|
||||
result = func(*args, **kwargs)
|
||||
assert result is not None, "Retrying must execute at least once"
|
||||
if result is None:
|
||||
raise RuntimeError("Retrying must execute at least once")
|
||||
return result
|
||||
|
||||
async def async_execute(
|
||||
@@ -480,7 +481,8 @@ class RetryContext:
|
||||
with attempt:
|
||||
self.attempt_count = attempt.retry_state.attempt_number
|
||||
result = cast(T, await func(*args, **kwargs)) # type: ignore[misc]
|
||||
assert result is not None, "AsyncRetrying must execute at least once"
|
||||
if result is None:
|
||||
raise RuntimeError("AsyncRetrying must execute at least once")
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Shared types for AI model domain objects."""
|
||||
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
__all__ = ["SchemaUrl"]
|
||||
|
||||
|
||||
class SchemaUrl(str, Enum):
|
||||
class SchemaUrl(StrEnum):
|
||||
"""Schema URL enumeration shared across AI model contracts."""
|
||||
|
||||
INPUT_CONFIG = "SchemaUrlInputConfig"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -19,14 +19,14 @@ def _empty_model_uses_provider_list() -> list[BaseModelUsesProvider]:
|
||||
return []
|
||||
|
||||
|
||||
class ModelOutputFormat(str, Enum):
|
||||
class ModelOutputFormat(StrEnum):
|
||||
"""Enum for ModelOutputFormat."""
|
||||
|
||||
TOOL_CALL_JSON = "ModelOutputFormatToolCallJson"
|
||||
XML = "ModelOutputFormatXml"
|
||||
|
||||
|
||||
class ReasoningEffort(str, Enum):
|
||||
class ReasoningEffort(StrEnum):
|
||||
"""Enum for ReasoningEffort."""
|
||||
|
||||
LOW = "ReasoningEffortLow"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Domain models for CleverAgents - auto-generated from Phase 0 stubs."""
|
||||
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ApiErrorType(str, Enum):
|
||||
class ApiErrorType(StrEnum):
|
||||
"""Enum for API error types."""
|
||||
|
||||
VALIDATION = "validation"
|
||||
|
||||
@@ -7,7 +7,7 @@ These models represent the conversation state and history for plans.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -49,14 +49,14 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class TellStage(str, Enum):
|
||||
class TellStage(StrEnum):
|
||||
"""Stage of the tell operation."""
|
||||
|
||||
PLANNING = "planning"
|
||||
IMPLEMENTATION = "implementation"
|
||||
|
||||
|
||||
class PlanningPhase(str, Enum):
|
||||
class PlanningPhase(StrEnum):
|
||||
"""Phase of the planning stage."""
|
||||
|
||||
CONTEXT = "context"
|
||||
|
||||
@@ -12,7 +12,7 @@ Based on v3_spec.md and ADR-004 (Pydantic Validation).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
@@ -23,7 +23,7 @@ from cleveragents.domain.models.core.plan import (
|
||||
)
|
||||
|
||||
|
||||
class ArgumentType(str, Enum):
|
||||
class ArgumentType(StrEnum):
|
||||
"""Supported types for action arguments."""
|
||||
|
||||
STRING = "str"
|
||||
@@ -33,7 +33,7 @@ class ArgumentType(str, Enum):
|
||||
LIST = "list"
|
||||
|
||||
|
||||
class ArgumentRequirement(str, Enum):
|
||||
class ArgumentRequirement(StrEnum):
|
||||
"""Whether an argument is required or optional."""
|
||||
|
||||
REQUIRED = "required"
|
||||
@@ -304,7 +304,7 @@ class Action(BaseModel):
|
||||
elif arg.max_value is not None and value > arg.max_value:
|
||||
errors.append(f"Argument {arg.name} must be <= {arg.max_value}")
|
||||
elif arg.arg_type == ArgumentType.FLOAT:
|
||||
if not isinstance(value, (int, float)):
|
||||
if not isinstance(value, int | float):
|
||||
errors.append(
|
||||
f"Argument {arg.name} must be a number, got {type_name}"
|
||||
)
|
||||
|
||||
@@ -4,12 +4,12 @@ Based on Phase 0 discovery and ADR-004 (Pydantic Validation).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class OperationType(str, Enum):
|
||||
class OperationType(StrEnum):
|
||||
"""Type of file operation."""
|
||||
|
||||
CREATE = "create"
|
||||
|
||||
@@ -6,7 +6,7 @@ Based on Phase 0 discovery and ADR-004 (Pydantic Validation).
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
@@ -20,7 +20,7 @@ def _empty_token_diffs() -> dict[str, dict[str, int]]:
|
||||
return {}
|
||||
|
||||
|
||||
class ContextType(str, Enum):
|
||||
class ContextType(StrEnum):
|
||||
"""Type of context item."""
|
||||
|
||||
FILE = "file"
|
||||
@@ -76,7 +76,7 @@ class Context(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class MaxContextCount(str, Enum):
|
||||
class MaxContextCount(StrEnum):
|
||||
"""Enum describing supported max context size presets."""
|
||||
|
||||
MB_25 = "25MB"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Enums for CleverAgents domain models."""
|
||||
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ModelPublisher(str, Enum):
|
||||
class ModelPublisher(StrEnum):
|
||||
"""Model publisher enumeration."""
|
||||
|
||||
OPENAI = "ModelPublisherOpenAI"
|
||||
@@ -15,7 +15,7 @@ class ModelPublisher(str, Enum):
|
||||
MISTRAL = "ModelPublisherMistral"
|
||||
|
||||
|
||||
class ModelErrKind(str, Enum):
|
||||
class ModelErrKind(StrEnum):
|
||||
"""Model error kind enumeration."""
|
||||
|
||||
OVERLOADED = "ErrOverloaded"
|
||||
@@ -26,7 +26,7 @@ class ModelErrKind(str, Enum):
|
||||
CACHE_SUPPORT = "ErrCacheSupport"
|
||||
|
||||
|
||||
class FallbackType(str, Enum):
|
||||
class FallbackType(StrEnum):
|
||||
"""Fallback type enumeration."""
|
||||
|
||||
ERROR = "FallbackTypeError"
|
||||
@@ -34,7 +34,7 @@ class FallbackType(str, Enum):
|
||||
PROVIDER = "FallbackTypeProvider"
|
||||
|
||||
|
||||
class ModelProvider(str, Enum):
|
||||
class ModelProvider(StrEnum):
|
||||
"""Model provider enumeration."""
|
||||
|
||||
OPENROUTER = "ModelProviderOpenRouter"
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -153,14 +153,14 @@ class Invite(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CreditsTransactionType(str, Enum):
|
||||
class CreditsTransactionType(StrEnum):
|
||||
"""Type of credits transaction."""
|
||||
|
||||
CREDIT = "CreditsTransactionTypeCredit"
|
||||
DEBIT = "CreditsTransactionTypeDebit"
|
||||
|
||||
|
||||
class CreditType(str, Enum):
|
||||
class CreditType(StrEnum):
|
||||
"""Categorization for credits changes."""
|
||||
|
||||
TRIAL = "CreditTypeTrial"
|
||||
|
||||
@@ -7,7 +7,7 @@ Based on spec.md and ADR-004 (Pydantic Validation).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
@@ -16,7 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
|
||||
|
||||
class PlanPhase(str, Enum):
|
||||
class PlanPhase(StrEnum):
|
||||
"""Phase of a plan in the v3 lifecycle.
|
||||
|
||||
Plans progress through phases in order:
|
||||
@@ -30,7 +30,7 @@ class PlanPhase(str, Enum):
|
||||
APPLIED = "applied"
|
||||
|
||||
|
||||
class ActionState(str, Enum):
|
||||
class ActionState(StrEnum):
|
||||
"""States for plans in the Action phase.
|
||||
|
||||
Actions are reusable plan templates not tied to any project yet.
|
||||
@@ -41,7 +41,7 @@ class ActionState(str, Enum):
|
||||
ARCHIVED = "archived" # Soft-deleted or hidden
|
||||
|
||||
|
||||
class ProcessingState(str, Enum):
|
||||
class ProcessingState(StrEnum):
|
||||
"""States for plans in Strategize/Execute/Apply phases.
|
||||
|
||||
These states indicate what is happening during processing.
|
||||
|
||||
@@ -4,12 +4,12 @@ Based on Phase 0 discovery and ADR-004 (Pydantic Validation).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PlanStatus(str, Enum):
|
||||
class PlanStatus(StrEnum):
|
||||
"""Status of a plan."""
|
||||
|
||||
PENDING = "pending"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Domain models for CleverAgents - auto-generated from Phase 0 stubs."""
|
||||
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AutoModeType(str, Enum):
|
||||
class AutoModeType(StrEnum):
|
||||
"""Enum for auto mode types."""
|
||||
|
||||
MANUAL = "manual"
|
||||
|
||||
@@ -7,7 +7,7 @@ These models represent plan file operations, results, and state.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -119,7 +119,7 @@ class PlanApply(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class PlanStateStatus(str, Enum):
|
||||
class PlanStateStatus(StrEnum):
|
||||
"""Status of the current plan state."""
|
||||
|
||||
IDLE = "idle"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Domain models for CleverAgents - auto-generated from Phase 0 stubs."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from ..auth.auth import ApiError
|
||||
|
||||
|
||||
class StreamMessageType(str, Enum):
|
||||
class StreamMessageType(StrEnum):
|
||||
"""Enum for stream message types."""
|
||||
|
||||
INFO = "info"
|
||||
|
||||
@@ -4,6 +4,7 @@ This module handles running Alembic migrations programmatically
|
||||
to ensure database schema is up to date.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
@@ -293,7 +294,9 @@ class MigrationRunner:
|
||||
)
|
||||
except Exception:
|
||||
# Fall through to auto-approval below if prompting fails
|
||||
pass
|
||||
logging.getLogger(__name__).debug(
|
||||
"Interactive prompt failed, auto-approving migrations"
|
||||
)
|
||||
|
||||
# Non-interactive or prompt failure: auto-approve to avoid blocking
|
||||
return True
|
||||
|
||||
@@ -143,6 +143,7 @@ class Node: # pylint: disable=too-many-instance-attributes
|
||||
try:
|
||||
return await self.execute(state)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self.logger.warning("Node %s retry attempt failed", self.name)
|
||||
continue
|
||||
return {"error": str(exc), "failed_node": self.name}
|
||||
finally:
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -35,7 +35,7 @@ def _coerce_optional_str(value: object | None) -> str | None:
|
||||
return str(value)
|
||||
|
||||
|
||||
class ProviderType(str, Enum):
|
||||
class ProviderType(StrEnum):
|
||||
"""Supported AI provider types."""
|
||||
|
||||
OPENAI = "openai"
|
||||
|
||||
@@ -5,6 +5,8 @@ semantics. Provider/model flags are not used; actors are resolved by name.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
@@ -22,9 +24,9 @@ from cleveragents.core.exceptions import StreamRoutingError
|
||||
from cleveragents.providers.registry import get_provider_registry
|
||||
|
||||
try:
|
||||
from jinja2 import Environment
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
except Exception: # pragma: no cover - optional runtime dependency guard
|
||||
Environment = None
|
||||
SandboxedEnvironment = None
|
||||
|
||||
try:
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
@@ -84,6 +86,65 @@ class StreamConfig(BaseModel): # pylint: disable=too-many-instance-attributes
|
||||
template_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST validation helpers for safe dynamic code execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# AST node types that are forbidden in user-supplied code blocks.
|
||||
_FORBIDDEN_AST_NODES: set[type[ast.AST]] = {
|
||||
ast.Import,
|
||||
ast.ImportFrom,
|
||||
ast.Global,
|
||||
ast.Nonlocal,
|
||||
}
|
||||
|
||||
logger_sr = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_code_ast(code: str) -> None:
|
||||
"""Parse *code* and reject any forbidden AST constructs.
|
||||
|
||||
Raises ``StreamRoutingError`` if the code contains imports, global/nonlocal
|
||||
statements, or calls to dangerous built-ins (exec, eval, compile, __import__).
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError as exc:
|
||||
raise StreamRoutingError(f"Invalid code syntax: {exc}") from exc
|
||||
|
||||
_DANGEROUS_NAMES = {"exec", "eval", "compile", "__import__", "getattr", "setattr"}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if type(node) in _FORBIDDEN_AST_NODES:
|
||||
raise StreamRoutingError(
|
||||
f"Forbidden construct in tool code: {type(node).__name__}"
|
||||
)
|
||||
# Reject calls to dangerous built-in names
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id in _DANGEROUS_NAMES
|
||||
):
|
||||
raise StreamRoutingError(f"Forbidden call in tool code: {node.func.id}()")
|
||||
|
||||
|
||||
def _validate_lambda_ast(fn_str: str) -> None:
|
||||
"""Validate that *fn_str* is a single lambda expression and nothing else.
|
||||
|
||||
Raises ``StreamRoutingError`` if the string is not a safe lambda.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(fn_str, mode="eval")
|
||||
except SyntaxError as exc:
|
||||
raise StreamRoutingError(f"Invalid transform function syntax: {exc}") from exc
|
||||
|
||||
if not isinstance(tree.body, ast.Lambda):
|
||||
raise StreamRoutingError(
|
||||
"Transform 'fn' must be a lambda expression, "
|
||||
f"got {type(tree.body).__name__}"
|
||||
)
|
||||
|
||||
|
||||
class SimpleToolAgent:
|
||||
"""Executes inline tool code blocks from agent config."""
|
||||
|
||||
@@ -103,6 +164,8 @@ class SimpleToolAgent:
|
||||
code = tool.get("code") if isinstance(tool, dict) else None
|
||||
if not code:
|
||||
return content
|
||||
# Validate AST before execution — reject imports, dangerous calls, etc.
|
||||
_validate_code_ast(code)
|
||||
local_vars: dict[str, Any] = {
|
||||
"input_data": content,
|
||||
"metadata": meta,
|
||||
@@ -110,7 +173,9 @@ class SimpleToolAgent:
|
||||
"result": None,
|
||||
}
|
||||
try:
|
||||
exec(code, {}, local_vars)
|
||||
exec(code, {}, local_vars) # nosec B102
|
||||
except StreamRoutingError:
|
||||
raise # Re-raise validation errors
|
||||
except Exception:
|
||||
return ""
|
||||
return local_vars.get("result", "")
|
||||
@@ -129,7 +194,9 @@ class SimpleLLMAgent:
|
||||
self.config = config or {}
|
||||
self.system_prompt = self.config.get("system_prompt", "") or ""
|
||||
self._llm = None
|
||||
self._template_env = Environment() if Environment is not None else None
|
||||
self._template_env = (
|
||||
SandboxedEnvironment() if SandboxedEnvironment is not None else None
|
||||
)
|
||||
|
||||
def _render_prompt(self, template: str, context: dict[str, Any]) -> str:
|
||||
if not template:
|
||||
@@ -337,7 +404,10 @@ class ReactiveStreamRouter: # pylint: disable=too-many-instance-attributes
|
||||
if "fn" in params:
|
||||
fn_str = params["fn"]
|
||||
try:
|
||||
transform_fn = eval(fn_str, {"__builtins__": {}})
|
||||
# Validate: only lambda expressions allowed
|
||||
_validate_lambda_ast(fn_str)
|
||||
compiled = compile(fn_str, "<transform>", "eval")
|
||||
transform_fn = eval(compiled, {"__builtins__": {}}) # nosec B307
|
||||
return ops.map(
|
||||
lambda msg: (
|
||||
transform_fn(msg.content)
|
||||
@@ -489,7 +559,7 @@ class ReactiveStreamRouter: # pylint: disable=too-many-instance-attributes
|
||||
if acc is None:
|
||||
acc = [] if config.get("init") == "list" else 0
|
||||
if config.get("op") == "sum":
|
||||
return acc + (msg if isinstance(msg, (int, float)) else 0)
|
||||
return acc + (msg if isinstance(msg, int | float) else 0)
|
||||
if config.get("op") == "append" and isinstance(acc, list):
|
||||
acc.append(msg)
|
||||
return acc
|
||||
@@ -563,11 +633,9 @@ class ReactiveStreamRouter: # pylint: disable=too-many-instance-attributes
|
||||
"""Dispose of all streams and subscriptions."""
|
||||
# Dispose subscriptions first
|
||||
for subscription in self.subscriptions[:]:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
if hasattr(subscription, "dispose"):
|
||||
subscription.dispose()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass # Ignore disposal errors
|
||||
|
||||
# Clear subscriptions
|
||||
self.subscriptions.clear()
|
||||
@@ -575,11 +643,9 @@ class ReactiveStreamRouter: # pylint: disable=too-many-instance-attributes
|
||||
# Dispose streams
|
||||
for stream_name in list(self.streams.keys()):
|
||||
stream = self.streams[stream_name]
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
if hasattr(stream, "dispose"):
|
||||
stream.dispose()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass # Ignore disposal errors
|
||||
|
||||
# Clear all dictionaries
|
||||
self.streams.clear()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Vulture whitelist for false positives and intentional unused code.
|
||||
# This file is referenced by vulture to suppress known false positives.
|
||||
# Each entry represents either:
|
||||
# - A variable required by a protocol/interface but not used in the body
|
||||
# - A public API entry point that vulture cannot trace
|
||||
|
||||
# Context manager __exit__ parameters required by protocol
|
||||
exc_tb # noqa: B018, F821
|
||||
|
||||
# Legacy migrator method parameter needed for mapping interface consistency
|
||||
build_data # noqa: B018, F821
|
||||
Reference in New Issue
Block a user