Files
Aperant/tests/test_integration_phase4.py
T
Andy bfc232825b feat(pr-review): evidence-based validation and trigger-driven exploration (#1593)
* gsd update

* docs: define v1 requirements with holistic PR understanding

29 requirements across 6 categories:
- Holistic PR Understanding (5) - context synthesis and passing
- Validation Pipeline (3) - finding-validator for all reviews
- Schema Enforcement (5) - VerificationEvidence required
- Prompt Improvements (6) - understand intent, evidence requirements
- Code Simplification (6) - remove programmatic filters
- Measurement (4) - 5 PRs to validate

Key addition: Pass gathered context (related files, import graph) to specialists.
Currently gathered but unused.

* feat(01-01): add Phase 0 synthesis instruction to orchestrator prompt

- Add 'Phase 0: Understand the PR Holistically' section before Phase 1
- Include PR UNDERSTANDING output format (intent, critical changes, risk areas, files to verify)
- Add explicit gate: 'Only AFTER completing Phase 0, proceed to Phase 1'
- Add 'Understand First' principle to Key Principles section

Covers: CONTEXT-01, CONTEXT-05

* feat(01-01): add related files and import graph to orchestrator prompt

- Add related files section categorizing tests vs dependencies/callers
- Add import graph section showing what files import/are imported by changed files
- Limit to 30 related files (15 tests, 15 deps) and 20 import entries
- Include actionable guidance for using the context

Covers: CONTEXT-02, CONTEXT-03

* feat(01-02): add investigation context to specialist agent descriptions

- security-reviewer: check related files for affected callers, verify tests
- quality-reviewer: check related files for pattern consistency
- logic-reviewer: check callers/dependents for broken assumptions
- codebase-fit-reviewer: use related files to understand existing patterns
- finding-validator: check related files for missed mitigations
- ai-triage-reviewer: unchanged (doesn't need related file guidance)

CONTEXT-04: Specialists now know which files to investigate beyond the diff

* feat(01-02): add specialist-specific delegation guidance to related files section

- Updated header: "Pass relevant files to specialists when delegating"
- Added per-specialist guidance for security, logic, quality, codebase-fit
- Added example delegation showing how to include related files in task

Orchestrator now knows HOW to pass investigation context to each specialist type

* feat(02-01): add VerificationEvidence class and update finding models

- Add VerificationEvidence class with required code_examined, line_range_examined, verification_method fields
- Add required verification field to BaseFinding
- Add required verification field to ParallelOrchestratorFinding
- Add is_impact_finding boolean field to ParallelOrchestratorFinding (default False)
- Add checked_for_handling_elsewhere boolean field to ParallelOrchestratorFinding (default False)
- Mark old evidence field as DEPRECATED in both BaseFinding and ParallelOrchestratorFinding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(02-01): add tests for schema enforcement and verification evidence

- Add TestVerificationEvidence class with 5 tests for VerificationEvidence model
- Add TestParallelOrchestratorFindingVerification class with 6 tests for verification requirement
- Add TestVerificationSchemaGeneration class with 2 tests for JSON schema generation
- Update existing TestSecurityFinding and TestDeepAnalysisFinding to include verification field
- Import VerificationEvidence, ParallelOrchestratorFinding, BaseFinding in test imports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(03-02): add 'What the Diff Is For' section to orchestrator

- Reframe diff as question to investigate, not document to nitpick
- Add 3 questions to answer before delegation
- Include 'Delegate with Context' guidance
- Position after Phase 0, before Phase 1

* feat(03-01): add Understand Intent phase to all specialist prompts

- Add Phase 1: Understand the PR Intent to security, logic, quality, codebase_fit agents
- Force AI to understand PR purpose before searching for issues
- Prevents flagging intentional design decisions as bugs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(03-02): enhance delegation guidance with context requirements

- Add Context-Rich Delegation section with 3 requirements
- Include PR intent summary, specific concerns, files of interest
- Show anti-pattern vs good pattern comparison
- Update example delegation with specific verification items

* feat(03-01): add Evidence Requirements and Valid Outputs sections

- Add Evidence Requirements section documenting VerificationEvidence schema
- Document code_examined, line_range_examined, verification_method fields
- Document is_impact_finding and checked_for_handling_elsewhere fields
- Add Valid Outputs section allowing no-issues as valid output
- Document invalid outputs (forced issues, theoretical edge cases)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(03-01): update output format examples with verification object

- Add verification object with code_examined, line_range_examined, verification_method
- Add is_impact_finding and checked_for_handling_elsewhere fields
- Use domain-appropriate verification_method values per agent
- Security: direct_code_inspection for injection examples
- Logic: direct_code_inspection for off-by-one and race conditions
- Quality: direct_code_inspection + cross_file_trace for duplication
- Codebase fit: cross_file_trace for reinvention, direct_code_inspection for naming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(04-01): add _verify_line_numbers() method

- Pre-filter findings with invalid line numbers before AI validation
- Cache file line counts to avoid re-reading same file
- Reject findings where line > file length
- Log each rejection with finding ID and reason
- Conservative: allow findings if file read fails

* feat(04-02): add hypothesis-validation structure to finding validator

- Add "Hypothesis-Validation Structure (MANDATORY)" section with 4 steps
- Define TRUE/FALSE conditions for hypothesis testing
- Include worked example showing confirmed_valid conclusion path
- Include counter-example showing dismissed_false_positive path
- Reference structure from Investigation Process section

* feat(04-01): add _validate_findings() method

- Import FindingValidationResponse from pydantic_models
- Create finding-validator agent client with pr_finding_validator type
- Build validation prompt with findings JSON and changed files
- Filter findings by validation_status:
  - confirmed_valid: keep with validation evidence
  - dismissed_false_positive: exclude from results
  - needs_human_review: keep with [NEEDS REVIEW] prefix
- Fail-safe: return original findings on any error
- Log validation statistics

* feat(04-01): wire validation pipeline into review() method

- Stage 1: Line verification after cross-validation (cheap pre-filter)
- Stage 2: AI validation for findings that pass line check
- Update programmatic filter loop to use validated_by_ai
- Log validation statistics at each stage
- Uses project_root (worktree or fallback) for file access

* refactor(05-01): remove evidence filter and confidence routing from review()

- Remove _validate_finding_evidence() call from loop
- Remove _apply_confidence_routing() call
- Simplify loop to only check scope
- Replace routed_findings with direct validated_findings assignment

* feat(05-02): remove false positive patterns from validator

- Remove VAGUE_PATTERNS constant (10 patterns)
- Remove GENERIC_PATTERNS constant (6 patterns)
- Remove _is_false_positive() method (44 lines)
- Remove _is_false_positive call from _is_valid()
- Remove TestFalsePositiveDetection class (4 tests)
- Update test_low_severity_higher_threshold to use actionability score

REMOVE-04: VAGUE_PATTERNS, GENERIC_PATTERNS deleted
REMOVE-05: _is_false_positive() method deleted

* refactor(05-01): remove redundant functions and simplify scope check

- Remove ConfidenceTier enum (no longer used)
- Remove _validate_finding_evidence function (schema enforces evidence)
- Remove _apply_confidence_routing method (validation is binary)
- Remove 'from enum import Enum' import
- Simplify _is_finding_in_scope to use schema field is_impact_finding
  instead of keyword detection

* fix(pr-review): add Task tool to orchestrator configs for SDK subagents

The pr_orchestrator_parallel and pr_followup_parallel agents need the
Task tool in their tools list to invoke SDK subagents (security-reviewer,
logic-reviewer, etc.). Without Task, the SDK cannot spawn subagents,
resulting in "Agent type not found" errors.

Also fixes test_integration_phase4.py to set is_impact_finding as an
attribute rather than constructor arg, since PRReviewFinding doesn't
have this field (it's on ParallelOrchestratorFinding Pydantic model).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): add explicit Task tool invocation syntax for specialist agents

The orchestrator was using the built-in general-purpose agent instead of
our custom specialist agents (security-reviewer, logic-reviewer, etc.)
because the prompt described agents but didn't show explicit Task tool
invocation syntax.

Changes:
- Add "CRITICAL: How to Invoke Specialist Agents" section with exact
  subagent_type values in a reference table
- Add Task tool invocation format with example syntax
- Add example showing parallel invocation of multiple specialists
- Add explicit "DO NOT USE" section warning against general-purpose
- Update example delegation to use Task tool syntax instead of prose
- Add example validation invocation for finding-validator

This ensures Claude uses our custom specialists instead of defaulting
to the built-in general-purpose agent.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(pr-review): implement evidence-based validation and trigger-driven exploration

Major enhancements to the PR review system:

**Evidence-Based Validation:**
- Shift from confidence-based to evidence-based finding validation
- All findings now require VerificationEvidence with code_examined, line_range_examined
- finding-validator validates ALL findings (CRITICAL through LOW) before output
- Add dismissed_findings array for transparency - users see what was investigated

**Trigger-Driven Exploration (6 Semantic Triggers):**
- OUTPUT CONTRACT CHANGED - function returns different value/type/structure
- INPUT CONTRACT CHANGED - parameters added/removed/reordered
- BEHAVIORAL CONTRACT CHANGED - same I/O but different internal behavior
- SIDE EFFECT CONTRACT CHANGED - observable effects added/removed
- FAILURE CONTRACT CHANGED - error handling changed
- NULL/UNDEFINED CONTRACT CHANGED - null handling changed

Orchestrator detects triggers in Phase 1 and passes them to specialists
with explicit "TRIGGER:", "EXPLORATION REQUIRED:", "Stop when:" instructions.

**Implementation Changes:**
- Add _PRDebugLogger for comprehensive agent communication logging
- Add CI status integration to verdict logic (failing CI blocks merge)
- Extract with_working_dir() to shared agent_utils.py module
- Inject working directory into all subagent prompts
- Bump SDK requirement to >=0.1.22 for custom subagent support

**Frontend:**
- Tighten AUTH_FAILURE_PATTERNS to avoid false positives on AI auth discussion
- Update tests for new pattern requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): wait for both queued AND in_progress CI checks

Previously, the CI wait logic only blocked on "in_progress" checks,
but not "queued" checks. This meant if a CI check (like CodeRabbit)
was queued but not yet running, the review would start immediately
and report "CI is pending" - which would be stale by the time the
contributor sees it.

Now we wait for ALL checks to reach "completed" status before
starting the review, ensuring the CI status in our review is accurate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove duplicate .planning entries from .gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove docs/ from git tracking (already in .gitignore)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): propagate is_impact_finding field to allow impact findings

The is_impact_finding field was defined in ParallelOrchestratorFinding
but never propagated to PRReviewFinding, causing ALL impact findings
(findings about callers/affected files outside the PR's changed files)
to be incorrectly filtered out as "not in scope".

Changes:
- Add is_impact_finding field to PRReviewFinding dataclass
- Extract and pass is_impact_finding in _create_finding_from_structured()
- Add to to_dict() and from_dict() for serialization

This enables the trigger-driven exploration feature to actually work,
allowing the review to report issues in files affected by contract changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): add Task tool invocation syntax to followup orchestrator

The follow-up review orchestrator was missing explicit Task tool
invocation syntax and examples. The AI didn't know HOW to invoke
the specialist agents (resolution-verifier, finding-validator, etc.),
causing resolution checking to never happen.

Added:
- Exact agent names table (subagent_type values)
- Task tool invocation format with examples
- Complete follow-up review workflow with Task calls
- DO NOT USE section (avoid general-purpose, Explore, Plan)
- Decision matrix for when to invoke each agent
- Explicit Task tool calls in Phase 2 workflow

This matches the main orchestrator prompt which has extensive
Task tool examples and works correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): propagate is_impact_finding in follow-up reviewer

Applied the same is_impact_finding propagation fix to the follow-up
reviewer that was already applied to the main orchestrator reviewer.

Fixes:
1. Add is_impact_finding field to ParallelFollowupFinding Pydantic model
2. Propagate is_impact_finding when creating PRReviewFinding for new findings
3. Copy is_impact_finding from original finding for unresolved findings

Without this fix, impact findings (about callers/affected files outside
the PR's changed files) would be incorrectly filtered as "not in scope"
during follow-up reviews.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(auth): enhance keychain service integration with config directory support

Added functionality to support profile-specific credentials by introducing a hash-based service name for macOS Keychain and updating Windows credential retrieval to utilize a provided config directory. This ensures that tokens are fetched from the correct profile-specific storage locations, improving credential management across different environments.

Changes include:
- New functions for calculating config directory hashes and generating keychain service names.
- Updated `get_token_from_keychain` and related functions to accept an optional config directory argument.
- Enhanced logging for better debugging when no token is found.

This aligns the backend credential handling with the frontend's expectations for profile-specific storage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:34:08 +01:00

700 lines
27 KiB
Python

"""
Integration Tests for PR Review System - Phase 4+
==================================================
Tests validating key features:
- Phase 2: Import detection (path aliases, Python), reverse dependencies
- Phase 3: Multi-agent cross-validation
- Phase 5+: Scope filtering with is_impact_finding schema field
Note: ConfidenceTier and _validate_finding_evidence were removed in Phase 5
(Code Simplification). Evidence validation is now handled by schema enforcement
and the finding-validator agent.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add the backend directory to path for imports
backend_path = Path(__file__).parent.parent / "apps" / "backend"
sys.path.insert(0, str(backend_path))
# Import directly to avoid loading the full runners module with its dependencies
import importlib.util
# Load file_lock first (models.py depends on it)
file_lock_spec = importlib.util.spec_from_file_location(
"file_lock",
backend_path / "runners" / "github" / "file_lock.py"
)
file_lock_module = importlib.util.module_from_spec(file_lock_spec)
sys.modules['file_lock'] = file_lock_module
file_lock_spec.loader.exec_module(file_lock_module)
# Load models next
models_spec = importlib.util.spec_from_file_location(
"models",
backend_path / "runners" / "github" / "models.py"
)
models_module = importlib.util.module_from_spec(models_spec)
sys.modules['models'] = models_module
models_spec.loader.exec_module(models_module)
PRReviewFinding = models_module.PRReviewFinding
PRReviewResult = models_module.PRReviewResult
ReviewSeverity = models_module.ReviewSeverity
ReviewCategory = models_module.ReviewCategory
# Load services module dependencies for parallel_orchestrator_reviewer
category_utils_spec = importlib.util.spec_from_file_location(
"category_utils",
backend_path / "runners" / "github" / "services" / "category_utils.py"
)
category_utils_module = importlib.util.module_from_spec(category_utils_spec)
sys.modules['services.category_utils'] = category_utils_module
category_utils_spec.loader.exec_module(category_utils_module)
# Load io_utils
io_utils_spec = importlib.util.spec_from_file_location(
"io_utils",
backend_path / "runners" / "github" / "services" / "io_utils.py"
)
io_utils_module = importlib.util.module_from_spec(io_utils_spec)
sys.modules['services.io_utils'] = io_utils_module
io_utils_spec.loader.exec_module(io_utils_module)
# Load pydantic_models
pydantic_models_spec = importlib.util.spec_from_file_location(
"pydantic_models",
backend_path / "runners" / "github" / "services" / "pydantic_models.py"
)
pydantic_models_module = importlib.util.module_from_spec(pydantic_models_spec)
sys.modules['services.pydantic_models'] = pydantic_models_module
pydantic_models_spec.loader.exec_module(pydantic_models_module)
AgentAgreement = pydantic_models_module.AgentAgreement
# Load agent_utils (shared utility for working directory injection)
agent_utils_spec = importlib.util.spec_from_file_location(
"agent_utils",
backend_path / "runners" / "github" / "services" / "agent_utils.py"
)
agent_utils_module = importlib.util.module_from_spec(agent_utils_spec)
sys.modules['services.agent_utils'] = agent_utils_module
agent_utils_spec.loader.exec_module(agent_utils_module)
# Load parallel_orchestrator_reviewer (contains _is_finding_in_scope and _cross_validate_findings)
orchestrator_spec = importlib.util.spec_from_file_location(
"parallel_orchestrator_reviewer",
backend_path / "runners" / "github" / "services" / "parallel_orchestrator_reviewer.py"
)
orchestrator_module = importlib.util.module_from_spec(orchestrator_spec)
# Mock dependencies that aren't needed for unit testing
# IMPORTANT: Save and restore ALL mocked modules to avoid polluting sys.modules for other tests
_modules_to_mock = [
'context_gatherer',
'core.client',
'gh_client',
'phase_config',
'services.pr_worktree_manager',
'services.sdk_utils',
'claude_agent_sdk',
]
_original_modules = {name: sys.modules.get(name) for name in _modules_to_mock}
for name in _modules_to_mock:
sys.modules[name] = MagicMock()
orchestrator_spec.loader.exec_module(orchestrator_module)
# Restore all mocked modules to avoid polluting other tests
for name in _modules_to_mock:
if _original_modules[name] is not None:
sys.modules[name] = _original_modules[name]
elif name in sys.modules:
del sys.modules[name]
# Import only functions that still exist after Phase 5
_is_finding_in_scope = orchestrator_module._is_finding_in_scope
# =============================================================================
# Phase 5+ Tests: Scope Filtering (Updated)
# =============================================================================
class TestScopeFiltering:
"""Test scope filtering logic (updated for Phase 5 - uses is_impact_finding schema field)."""
@pytest.fixture
def make_finding(self):
"""Factory fixture to create PRReviewFinding instances.
Note: is_impact_finding is set as an attribute after creation because
PRReviewFinding (dataclass) doesn't have this field - it's on the
ParallelOrchestratorFinding Pydantic model. The actual code uses
getattr(finding, 'is_impact_finding', False) to access it.
"""
def _make_finding(
file: str = "src/test.py",
line: int = 10,
is_impact_finding: bool = False,
**kwargs
):
defaults = {
"id": "TEST001",
"severity": ReviewSeverity.MEDIUM,
"category": ReviewCategory.QUALITY,
"title": "Test Finding",
"description": "Test description",
"file": file,
"line": line,
}
defaults.update(kwargs)
finding = PRReviewFinding(**defaults)
# Set is_impact_finding as attribute (accessed via getattr in _is_finding_in_scope)
finding.is_impact_finding = is_impact_finding
return finding
return _make_finding
def test_finding_in_changed_files_passes(self, make_finding):
"""Finding for a file in changed_files should pass."""
changed_files = ["src/auth.py", "src/utils.py", "tests/test_auth.py"]
finding = make_finding(file="src/auth.py", line=15)
is_valid, reason = _is_finding_in_scope(finding, changed_files)
assert is_valid, f"Failed: {reason}"
def test_finding_outside_changed_files_filtered(self, make_finding):
"""Finding for a file NOT in changed_files should be filtered."""
changed_files = ["src/auth.py", "src/utils.py"]
finding = make_finding(
file="src/database.py",
line=10,
description="This code has a bug"
)
is_valid, reason = _is_finding_in_scope(finding, changed_files)
assert not is_valid
assert "not in pr changed files" in reason.lower()
def test_invalid_line_number_filtered(self, make_finding):
"""Finding with invalid line number (<=0) should be filtered."""
changed_files = ["src/test.py"]
# Zero line
finding = make_finding(file="src/test.py", line=0)
is_valid, reason = _is_finding_in_scope(finding, changed_files)
assert not is_valid
assert "invalid line" in reason.lower()
# Negative line
finding = make_finding(file="src/test.py", line=-5)
is_valid, reason = _is_finding_in_scope(finding, changed_files)
assert not is_valid
def test_impact_finding_allowed_for_unchanged_files(self, make_finding):
"""Finding with is_impact_finding=True should be allowed for unchanged files."""
changed_files = ["src/auth.py"]
# Impact finding for unchanged file
finding = make_finding(
file="src/utils.py",
line=10,
is_impact_finding=True, # Schema field replaces keyword detection
description="This change breaks the helper function in utils.py"
)
is_valid, _ = _is_finding_in_scope(finding, changed_files)
assert is_valid
def test_non_impact_finding_filtered_for_unchanged_files(self, make_finding):
"""Finding with is_impact_finding=False should be filtered for unchanged files."""
changed_files = ["src/auth.py"]
# Non-impact finding for unchanged file
finding = make_finding(
file="src/database.py",
line=20,
is_impact_finding=False,
description="database.py depends on modified auth module"
)
is_valid, reason = _is_finding_in_scope(finding, changed_files)
assert not is_valid
assert "not in pr changed files" in reason.lower()
def test_no_file_specified_fails(self, make_finding):
"""Finding with no file specified should fail."""
changed_files = ["src/test.py"]
finding = make_finding(file="")
is_valid, reason = _is_finding_in_scope(finding, changed_files)
assert not is_valid
assert "no file" in reason.lower()
def test_none_line_number_passes(self, make_finding):
"""Finding with None line number should pass (general finding)."""
changed_files = ["src/test.py"]
finding = make_finding(file="src/test.py", line=None)
# Line=None means general file-level finding
finding.line = None # Override since fixture sets it
is_valid, _ = _is_finding_in_scope(finding, changed_files)
assert is_valid
# =============================================================================
# Phase 2 Tests: Import Detection, Reverse Dependencies
# =============================================================================
# For Phase 2 tests, we need the real PRContextGatherer methods
# We'll test the functions directly by extracting the relevant logic
github_dir = backend_path / "runners" / "github"
# Load context_gatherer module directly using spec loader
# This avoids the complex package import chain
_cg_spec = importlib.util.spec_from_file_location(
"context_gatherer_isolated",
github_dir / "context_gatherer.py"
)
_cg_module = importlib.util.module_from_spec(_cg_spec)
# Set up minimal module environment
sys.modules['context_gatherer_isolated'] = _cg_module
# Mock only the gh_client dependency
_mock_gh = MagicMock()
sys.modules['gh_client'] = _mock_gh
_cg_spec.loader.exec_module(_cg_module)
PRContextGathererIsolated = _cg_module.PRContextGatherer
class TestImportDetection:
"""Test import detection logic (Phase 2)."""
@pytest.fixture
def temp_project(self, tmp_path):
"""Create a temporary project structure for import testing."""
# Create src directory
src_dir = tmp_path / "src"
src_dir.mkdir()
# Create utils.ts file
(src_dir / "utils.ts").write_text("export const helper = () => {};")
# Create config.ts file
(src_dir / "config.ts").write_text("export const config = { debug: true };")
# Create index.ts that re-exports
(src_dir / "index.ts").write_text("export * from './utils';\nexport { config } from './config';")
# Create shared directory
shared_dir = src_dir / "shared"
shared_dir.mkdir()
(shared_dir / "types.ts").write_text("export type User = { id: string };")
# Create Python module
(src_dir / "python_module.py").write_text("from .helpers import util_func\nimport os")
(src_dir / "helpers.py").write_text("def util_func(): pass")
(src_dir / "__init__.py").write_text("")
return tmp_path
def test_path_alias_detection(self, temp_project):
"""Path alias imports (@/utils) should be detected and resolved."""
import json
# Create tsconfig.json with path aliases
tsconfig = {
"compilerOptions": {
"paths": {
"@/*": ["src/*"],
"@shared/*": ["src/shared/*"]
}
}
}
(temp_project / "tsconfig.json").write_text(json.dumps(tsconfig))
# Create the target file that the alias points to
(temp_project / "src" / "utils.ts").write_text("export const helper = () => {};")
# Test file with alias import
test_content = "import { helper } from '@/utils';"
source_path = Path("src/test.ts")
gatherer = PRContextGathererIsolated(temp_project, pr_number=1)
# Call _find_imports
imports = gatherer._find_imports(test_content, source_path)
# Should resolve @/utils to src/utils.ts
assert isinstance(imports, set)
# Normalize paths for cross-platform comparison (Windows uses backslashes)
normalized_imports = {p.replace("\\", "/") for p in imports}
assert "src/utils.ts" in normalized_imports, f"Expected 'src/utils.ts' in imports, got: {imports}"
def test_commonjs_require_detection(self, temp_project):
"""CommonJS require('./utils') should be detected."""
test_content = "const utils = require('./utils');"
source_path = Path("src/test.ts")
gatherer = PRContextGathererIsolated(temp_project, pr_number=1)
imports = gatherer._find_imports(test_content, source_path)
# Should detect relative require
# Normalize paths for cross-platform comparison (Windows uses backslashes)
normalized_imports = {p.replace("\\", "/") for p in imports}
assert "src/utils.ts" in normalized_imports
def test_reexport_detection(self, temp_project):
"""Re-exports (export * from './module') should be detected."""
test_content = "export * from './utils';\nexport { config } from './config';"
source_path = Path("src/index.ts")
gatherer = PRContextGathererIsolated(temp_project, pr_number=1)
imports = gatherer._find_imports(test_content, source_path)
# Should detect re-export targets
# Normalize paths for cross-platform comparison (Windows uses backslashes)
normalized_imports = {p.replace("\\", "/") for p in imports}
assert "src/utils.ts" in normalized_imports
assert "src/config.ts" in normalized_imports
def test_python_relative_import(self, temp_project):
"""Python relative imports (from .utils import) should be detected via AST."""
test_content = "from .helpers import util_func"
source_path = Path("src/python_module.py")
gatherer = PRContextGathererIsolated(temp_project, pr_number=1)
imports = gatherer._find_imports(test_content, source_path)
# Should resolve relative Python import
# Normalize paths for cross-platform comparison (Windows uses backslashes)
normalized_imports = {p.replace("\\", "/") for p in imports}
assert "src/helpers.py" in normalized_imports
def test_python_absolute_import(self, temp_project):
"""Python absolute imports should be checked for project-internal modules."""
# Create a project-internal module
(temp_project / "myapp").mkdir()
(temp_project / "myapp" / "__init__.py").write_text("")
(temp_project / "myapp" / "config.py").write_text("DEBUG = True")
test_content = "from myapp import config"
source_path = Path("src/test.py")
gatherer = PRContextGathererIsolated(temp_project, pr_number=1)
imports = gatherer._find_imports(test_content, source_path)
# Should resolve absolute import to project module
# Normalize paths for cross-platform comparison (Windows uses backslashes)
normalized_imports = {p.replace("\\", "/") for p in imports}
assert any("myapp" in i for i in normalized_imports)
class TestReverseDepDetection:
"""Test reverse dependency detection (Phase 2)."""
@pytest.fixture
def temp_project_with_deps(self, tmp_path):
"""Create a project with files that import each other."""
src_dir = tmp_path / "src"
src_dir.mkdir()
# Create a utility file with non-generic name (helpers is in skip list)
(src_dir / "formatter.ts").write_text(
"export function format(s: string) { return s; }"
)
# Create files that import formatter
(src_dir / "auth.ts").write_text(
"import { format } from './formatter';\nexport const login = () => {};"
)
(src_dir / "api.ts").write_text(
"import { format } from './formatter';\nexport const fetch = () => {};"
)
# Create a file that does NOT import formatter
(src_dir / "standalone.ts").write_text(
"export const standalone = () => {};"
)
return tmp_path
def test_finds_files_importing_changed_file(self, temp_project_with_deps):
"""Verify grep-based detection finds files that import a given file."""
gatherer = PRContextGathererIsolated(temp_project_with_deps, pr_number=1)
# Use non-generic name (helpers is in the skip list)
dependents = gatherer._find_dependents("src/formatter.ts", max_results=10)
# Should find auth.ts and api.ts as dependents
assert any("auth.ts" in d for d in dependents)
assert any("api.ts" in d for d in dependents)
# Should NOT include standalone.ts
assert not any("standalone.ts" in d for d in dependents)
def test_generic_names_not_skipped(self, tmp_path):
"""Generic names (index, main, utils) are no longer skipped - LLM decides relevance."""
src_dir = tmp_path / "src"
src_dir.mkdir()
# Create files with generic names
(src_dir / "index.ts").write_text("export * from './utils';")
(src_dir / "main.ts").write_text("import { x } from './index';")
gatherer = PRContextGathererIsolated(tmp_path, pr_number=1)
# Generic names should NOT be skipped anymore (behavior changed in Phase 4)
# The LLM-driven system decides what's relevant based on PR context
dependents_index = gatherer._find_dependents("src/index.ts")
# main.ts imports index, so it should be found as a dependent
assert "src/main.ts" in dependents_index
def test_respects_file_limit(self, tmp_path):
"""Large repo search should stop after reaching file limit."""
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "unique_name.ts").write_text("export const x = 1;")
gatherer = PRContextGathererIsolated(tmp_path, pr_number=1)
# Mock os.walk to generate more files than the limit (2000)
# This simulates a large codebase without creating actual files
def mock_walk(path):
# Yield a directory with 3000 TypeScript files
yield (str(path), [], [f"file{i}.ts" for i in range(3000)])
with patch.object(_cg_module.os, "walk", mock_walk):
# The function should stop after max_files_to_check (2000) files
# and return gracefully without hanging
dependents = gatherer._find_dependents("src/unique_name.ts")
# Should return a set (may be empty since mock files don't contain imports)
assert isinstance(dependents, set)
# =============================================================================
# Phase 3 Tests: Multi-Agent Cross-Validation
# =============================================================================
# Import the cross-validation function from orchestrator
ParallelOrchestratorReviewer = orchestrator_module.ParallelOrchestratorReviewer
class TestCrossValidation:
"""Test multi-agent cross-validation logic (Phase 3)."""
@pytest.fixture
def make_finding(self):
"""Factory fixture to create PRReviewFinding instances."""
def _make_finding(
id: str = "TEST001",
file: str = "src/test.py",
line: int = 10,
category: ReviewCategory = ReviewCategory.SECURITY,
severity: ReviewSeverity = ReviewSeverity.HIGH,
confidence: float = 0.7,
source_agents: list = None,
**kwargs
):
return PRReviewFinding(
id=id,
severity=severity,
category=category,
title=kwargs.get("title", "Test Finding"),
description=kwargs.get("description", "Test description"),
file=file,
line=line,
confidence=confidence,
source_agents=source_agents or [],
**{k: v for k, v in kwargs.items() if k not in ["title", "description"]}
)
return _make_finding
@pytest.fixture
def mock_reviewer(self, tmp_path):
"""Create a mock ParallelOrchestratorReviewer instance."""
from models import GitHubRunnerConfig
config = GitHubRunnerConfig(
token="test-token",
repo="test/repo"
)
# Create minimal directory structure
github_dir = tmp_path / ".auto-claude" / "github"
github_dir.mkdir(parents=True)
reviewer = ParallelOrchestratorReviewer(
project_dir=tmp_path,
github_dir=github_dir,
config=config
)
return reviewer
def test_multi_agent_agreement_boosts_confidence(self, make_finding, mock_reviewer):
"""When 2+ agents agree on same finding, confidence should increase by 0.15."""
# Two findings from different agents on same (file, line, category)
finding1 = make_finding(
id="F1",
file="src/auth.py",
line=10,
category=ReviewCategory.SECURITY,
confidence=0.7,
source_agents=["security-reviewer"],
description="SQL injection risk"
)
finding2 = make_finding(
id="F2",
file="src/auth.py",
line=10,
category=ReviewCategory.SECURITY,
confidence=0.6,
source_agents=["quality-reviewer"],
description="Input not sanitized"
)
validated, agreement = mock_reviewer._cross_validate_findings([finding1, finding2])
# Should merge into one finding
assert len(validated) == 1
# Confidence should be boosted: max(0.7, 0.6) + 0.15 = 0.85
assert validated[0].confidence == pytest.approx(0.85, rel=0.01)
# Should have cross_validated flag set
assert validated[0].cross_validated is True
# Should track in agreement
assert len(agreement.agreed_findings) == 1
def test_confidence_boost_capped_at_095(self, make_finding, mock_reviewer):
"""Confidence boost should cap at 0.95, not exceed 1.0."""
finding1 = make_finding(
id="F1",
file="src/auth.py",
line=10,
category=ReviewCategory.SECURITY,
confidence=0.85,
source_agents=["security-reviewer"],
)
finding2 = make_finding(
id="F2",
file="src/auth.py",
line=10,
category=ReviewCategory.SECURITY,
confidence=0.90,
source_agents=["logic-reviewer"],
)
validated, _ = mock_reviewer._cross_validate_findings([finding1, finding2])
# 0.90 + 0.15 = 1.05, but should cap at 0.95
assert validated[0].confidence == 0.95
def test_merged_finding_has_cross_validated_true(self, make_finding, mock_reviewer):
"""Merged multi-agent findings should have cross_validated=True."""
finding1 = make_finding(id="F1", file="src/test.py", line=5, source_agents=["agent1"])
finding2 = make_finding(id="F2", file="src/test.py", line=5, source_agents=["agent2"])
validated, _ = mock_reviewer._cross_validate_findings([finding1, finding2])
assert validated[0].cross_validated is True
def test_grouping_by_file_line_category(self, make_finding, mock_reviewer):
"""Findings should be grouped by (file, line, category) tuple."""
# Same file+line but different category - should NOT merge
finding1 = make_finding(
id="F1",
file="src/test.py",
line=10,
category=ReviewCategory.SECURITY,
)
finding2 = make_finding(
id="F2",
file="src/test.py",
line=10,
category=ReviewCategory.QUALITY, # Different category
)
validated, _ = mock_reviewer._cross_validate_findings([finding1, finding2])
# Should remain as 2 separate findings
assert len(validated) == 2
# Same category but different line - should NOT merge
finding3 = make_finding(
id="F3",
file="src/test.py",
line=10,
category=ReviewCategory.SECURITY,
)
finding4 = make_finding(
id="F4",
file="src/test.py",
line=20, # Different line
category=ReviewCategory.SECURITY,
)
validated2, _ = mock_reviewer._cross_validate_findings([finding3, finding4])
assert len(validated2) == 2
def test_merged_description_combines_sources(self, make_finding, mock_reviewer):
"""Merged findings should combine descriptions with ' | ' separator."""
finding1 = make_finding(
id="F1",
file="src/auth.py",
line=10,
category=ReviewCategory.SECURITY,
description="SQL injection vulnerability",
)
finding2 = make_finding(
id="F2",
file="src/auth.py",
line=10,
category=ReviewCategory.SECURITY,
description="Unsanitized user input",
)
validated, _ = mock_reviewer._cross_validate_findings([finding1, finding2])
# Should combine descriptions with ' | '
assert " | " in validated[0].description
assert "SQL injection vulnerability" in validated[0].description
assert "Unsanitized user input" in validated[0].description
def test_single_agent_finding_not_boosted(self, make_finding, mock_reviewer):
"""Single-agent findings should not have confidence boosted."""
finding = make_finding(
id="F1",
file="src/test.py",
line=10,
confidence=0.7,
source_agents=["security-reviewer"],
)
validated, agreement = mock_reviewer._cross_validate_findings([finding])
# Confidence should remain unchanged
assert validated[0].confidence == 0.7
# Should not be marked as cross-validated
assert validated[0].cross_validated is False
# Should not be in agreed_findings
assert len(agreement.agreed_findings) == 0
def test_merged_finding_keeps_highest_severity(self, make_finding, mock_reviewer):
"""Merged findings should keep the highest severity."""
finding1 = make_finding(
id="F1",
file="src/test.py",
line=10,
severity=ReviewSeverity.MEDIUM,
)
finding2 = make_finding(
id="F2",
file="src/test.py",
line=10,
severity=ReviewSeverity.CRITICAL,
)
validated, _ = mock_reviewer._cross_validate_findings([finding1, finding2])
# Should keep CRITICAL (highest severity)
assert validated[0].severity == ReviewSeverity.CRITICAL
def test_empty_findings_handled(self, mock_reviewer):
"""Test that empty findings list is handled gracefully."""
validated, agreement = mock_reviewer._cross_validate_findings([])
assert len(validated) == 0
assert len(agreement.agreed_findings) == 0
assert len(agreement.conflicting_findings) == 0