Skip to content

Commit 548f727

Browse files
committed
fix(forge): address validation issues in Forge orchestration system
- Fix doctest in protocol.rs to use correct exported types from agents/mod.rs (ValidationStatus::Passed, RuleInfo::new with 3 args, is_passed() method) - Add proper semver comparison in security.rs instead of string comparison (fixes version comparison for cases like '0.4.9' vs '0.4.20') - Fix scroll_offset persistence in forge.rs view (now properly persists across renders) - Add robust test file detection using path boundary checks instead of substring contains (avoids false positives for 'contest.rs', 'attestation.rs', etc.) - Replace Arc::try_unwrap with lock().clone() in orchestrator for robustness (handles fail-fast early exit when tasks still hold Arc clones) - Fix unwrap_or(&mut 0) pattern with proper if-let handling - Apply clippy fixes (is_some_and, pattern arrays, redundant closures)
1 parent b12df1e commit 548f727

5 files changed

Lines changed: 181 additions & 41 deletions

File tree

src/cortex-agents/src/forge/agents/quality.rs

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,8 @@ impl QualityAgent {
292292

293293
// Check for bare .unwrap() calls (not followed by expect-like context)
294294
if line.contains(".unwrap()") {
295-
// Check if it's in a test (heuristic: file or function name contains test)
296-
let is_test_file = file_path.to_string_lossy().contains("test")
297-
|| file_path
298-
.file_stem()
299-
.map_or(false, |s| s.to_string_lossy().ends_with("_test"));
295+
// Check if it's in a test file using robust heuristics
296+
let is_test_file = is_test_path(file_path);
300297

301298
if !is_test_file {
302299
// Check if the next line or same line has a comment explaining it
@@ -659,7 +656,7 @@ async fn collect_files_recursive(
659656

660657
if path.is_dir() {
661658
collect_files_recursive(&path, ctx, files).await?;
662-
} else if path.is_file() && path.extension().map_or(false, |ext| ext == "rs") {
659+
} else if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
663660
files.push(path);
664661
}
665662
}
@@ -677,6 +674,52 @@ fn truncate_line(line: &str, max_len: usize) -> String {
677674
}
678675
}
679676

677+
/// Check if a file path represents a test file.
678+
///
679+
/// Uses robust heuristics to detect test files:
680+
/// - Files in a `tests/` or `test/` directory
681+
/// - Files ending with `_test.rs` or `_tests.rs`
682+
/// - Files named `test.rs` or `tests.rs`
683+
/// - Files containing `/tests/` or `/test/` in their path
684+
fn is_test_path(file_path: &Path) -> bool {
685+
let path_str = file_path.to_string_lossy();
686+
687+
// Check for standard test directories (with boundary detection)
688+
// The path separators ensure we don't match "contest/", "latest/", etc.
689+
if path_str.contains("/tests/")
690+
|| path_str.contains("/test/")
691+
|| path_str.starts_with("tests/")
692+
|| path_str.starts_with("test/")
693+
{
694+
return true;
695+
}
696+
697+
// Check file stem (name without extension)
698+
if let Some(file_stem) = file_path.file_stem() {
699+
let stem = file_stem.to_string_lossy();
700+
701+
// Check for test file naming conventions
702+
if stem == "test" || stem == "tests" || stem.ends_with("_test") || stem.ends_with("_tests")
703+
{
704+
return true;
705+
}
706+
707+
// Check for `mod tests` pattern (file named `tests.rs`)
708+
if stem == "mod" {
709+
if let Some(parent) = file_path.parent() {
710+
if let Some(parent_name) = parent.file_name() {
711+
let parent_str = parent_name.to_string_lossy();
712+
if parent_str == "tests" || parent_str == "test" {
713+
return true;
714+
}
715+
}
716+
}
717+
}
718+
}
719+
720+
false
721+
}
722+
680723
/// Extract item name from a Rust declaration.
681724
fn extract_item_name(line: &str) -> String {
682725
// Simple extraction - works for basic cases
@@ -685,7 +728,7 @@ fn extract_item_name(line: &str) -> String {
685728
// pub fn name, pub struct Name, etc.
686729
let name = parts[2];
687730
// Remove generic params and parentheses
688-
name.split(|c| c == '<' || c == '(' || c == '{')
731+
name.split(['<', '(', '{'])
689732
.next()
690733
.unwrap_or(name)
691734
.to_string()
@@ -735,4 +778,30 @@ mod tests {
735778
assert_eq!(agent.name(), "Code Quality Agent");
736779
assert!(agent.dependencies().is_empty());
737780
}
781+
782+
#[test]
783+
fn test_is_test_path() {
784+
use std::path::Path;
785+
786+
// Should detect test files
787+
assert!(is_test_path(Path::new("tests/unit.rs")));
788+
assert!(is_test_path(Path::new("src/tests/mod.rs")));
789+
assert!(is_test_path(Path::new("module_test.rs")));
790+
assert!(is_test_path(Path::new("foo_tests.rs")));
791+
assert!(is_test_path(Path::new("/project/tests/integration.rs")));
792+
assert!(is_test_path(Path::new("test/helper.rs")));
793+
794+
// Should NOT detect non-test files that contain 'test' substring
795+
assert!(!is_test_path(Path::new("contest.rs")));
796+
assert!(!is_test_path(Path::new("contest_manager.rs")));
797+
assert!(!is_test_path(Path::new("latest_report.rs")));
798+
assert!(!is_test_path(Path::new("greatest.rs")));
799+
assert!(!is_test_path(Path::new("src/attestation.rs")));
800+
assert!(!is_test_path(Path::new("testable_trait.rs"))); // Not a test file itself
801+
802+
// Regular source files
803+
assert!(!is_test_path(Path::new("src/main.rs")));
804+
assert!(!is_test_path(Path::new("src/lib.rs")));
805+
assert!(!is_test_path(Path::new("src/module/mod.rs")));
806+
}
738807
}

src/cortex-agents/src/forge/agents/security.rs

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ impl SecurityAgent {
253253
let files = collect_source_files(&ctx.project_path, ctx).await?;
254254

255255
for file_path in files {
256-
if file_path.extension().map_or(false, |ext| ext == "rs") {
256+
if file_path.extension().is_some_and(|ext| ext == "rs") {
257257
if let Err(e) = self
258258
.scan_file_for_unsafe(&file_path, rule_id, severity, findings)
259259
.await
@@ -333,7 +333,7 @@ impl SecurityAgent {
333333
let files = collect_source_files(&ctx.project_path, ctx).await?;
334334

335335
for file_path in files {
336-
if file_path.extension().map_or(false, |ext| ext == "rs") {
336+
if file_path.extension().is_some_and(|ext| ext == "rs") {
337337
if let Err(e) = self
338338
.scan_file_for_input_issues(&file_path, rule_id, severity, findings)
339339
.await
@@ -637,16 +637,48 @@ fn parse_cargo_lock(content: &str) -> Vec<(String, String)> {
637637
}
638638

639639
/// Check if a version matches a vulnerable version range.
640-
/// This is a simplified check - in production would use semver properly.
640+
/// Uses semantic version comparison for accurate results.
641641
fn version_matches_vulnerable(version: &str, vulnerable_pattern: &str) -> bool {
642642
if let Some(max_version) = vulnerable_pattern.strip_prefix('<') {
643-
// Simple string comparison - in production use semver crate
644-
version < max_version
643+
// Parse versions and compare semantically
644+
compare_semver(version, max_version) == Some(std::cmp::Ordering::Less)
645645
} else {
646646
version == vulnerable_pattern
647647
}
648648
}
649649

650+
/// Compare two semantic versions.
651+
/// Returns Ordering::Less if a < b, Equal if a == b, Greater if a > b.
652+
/// Returns None if parsing fails.
653+
fn compare_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
654+
let parse_version = |v: &str| -> Option<Vec<u64>> {
655+
v.split('.')
656+
.map(|part| {
657+
// Handle versions with pre-release suffixes like "1.0.0-alpha"
658+
let numeric_part = part.split('-').next()?;
659+
numeric_part.parse::<u64>().ok()
660+
})
661+
.collect()
662+
};
663+
664+
let a_parts = parse_version(a)?;
665+
let b_parts = parse_version(b)?;
666+
667+
// Compare each component
668+
let max_len = a_parts.len().max(b_parts.len());
669+
for i in 0..max_len {
670+
let a_val = a_parts.get(i).copied().unwrap_or(0);
671+
let b_val = b_parts.get(i).copied().unwrap_or(0);
672+
673+
match a_val.cmp(&b_val) {
674+
std::cmp::Ordering::Equal => continue,
675+
other => return Some(other),
676+
}
677+
}
678+
679+
Some(std::cmp::Ordering::Equal)
680+
}
681+
650682
#[cfg(test)]
651683
mod tests {
652684
use super::*;
@@ -681,9 +713,42 @@ version = "1.20.0"
681713

682714
#[test]
683715
fn test_version_matches_vulnerable() {
716+
// Basic comparison
684717
assert!(version_matches_vulnerable("0.4.19", "<0.4.20"));
685718
assert!(!version_matches_vulnerable("0.4.20", "<0.4.20"));
686719
assert!(!version_matches_vulnerable("0.5.0", "<0.4.20"));
720+
721+
// Semver edge cases - string comparison would fail these
722+
assert!(version_matches_vulnerable("0.4.9", "<0.4.20")); // '9' > '2' in string comparison
723+
assert!(version_matches_vulnerable("0.4.1", "<0.4.20"));
724+
assert!(!version_matches_vulnerable("0.4.21", "<0.4.20"));
725+
726+
// Major/minor version comparisons
727+
assert!(version_matches_vulnerable("0.3.99", "<0.4.20"));
728+
assert!(!version_matches_vulnerable("1.0.0", "<0.4.20"));
729+
730+
// Exact match
731+
assert!(version_matches_vulnerable("1.0.0", "1.0.0"));
732+
assert!(!version_matches_vulnerable("1.0.1", "1.0.0"));
733+
}
734+
735+
#[test]
736+
fn test_compare_semver() {
737+
use std::cmp::Ordering;
738+
739+
assert_eq!(compare_semver("1.0.0", "2.0.0"), Some(Ordering::Less));
740+
assert_eq!(compare_semver("2.0.0", "1.0.0"), Some(Ordering::Greater));
741+
assert_eq!(compare_semver("1.0.0", "1.0.0"), Some(Ordering::Equal));
742+
743+
// Multi-digit version numbers
744+
assert_eq!(compare_semver("0.4.9", "0.4.20"), Some(Ordering::Less));
745+
assert_eq!(compare_semver("0.4.19", "0.4.20"), Some(Ordering::Less));
746+
assert_eq!(compare_semver("0.4.20", "0.4.20"), Some(Ordering::Equal));
747+
assert_eq!(compare_semver("0.4.21", "0.4.20"), Some(Ordering::Greater));
748+
749+
// Different number of components
750+
assert_eq!(compare_semver("1.0", "1.0.0"), Some(Ordering::Equal));
751+
assert_eq!(compare_semver("1.0.0", "1.0"), Some(Ordering::Equal));
687752
}
688753

689754
#[test]

src/cortex-agents/src/forge/orchestrator.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -416,14 +416,17 @@ impl ForgeOrchestrator {
416416
}
417417
}
418418

419-
// Collect final results
420-
let final_results = Arc::try_unwrap(results)
421-
.map_err(|_| OrchestratorError::Internal("Failed to unwrap results".to_string()))?
422-
.into_inner();
419+
// Collect final results - use lock() which is more robust than try_unwrap()
420+
// when tasks might still be running (e.g., after fail-fast early exit)
421+
let final_results = {
422+
let guard = results.lock().await;
423+
guard.clone()
424+
};
423425

424-
let final_errors = Arc::try_unwrap(errors)
425-
.map_err(|_| OrchestratorError::Internal("Failed to unwrap errors".to_string()))?
426-
.into_inner();
426+
let final_errors = {
427+
let guard = errors.lock().await;
428+
guard.clone()
429+
};
427430

428431
let execution_time_ms = start_time.elapsed().as_millis() as u64;
429432

@@ -452,7 +455,10 @@ impl ForgeOrchestrator {
452455
for agent in enabled.values() {
453456
for dep in &agent.depends_on {
454457
if enabled.contains_key(dep) {
455-
*in_degree.get_mut(&agent.id).unwrap_or(&mut 0) += 1;
458+
// Increment in-degree - entry must exist since we initialized all enabled agents
459+
if let Some(degree) = in_degree.get_mut(&agent.id) {
460+
*degree += 1;
461+
}
456462
}
457463
}
458464
}

src/cortex-agents/src/forge/protocol.rs

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
//! Agent validation protocol for Forge orchestration.
22
//!
3-
//! This module defines the protocol for agent validation results,
3+
//! This module defines protocol types for agent validation results,
44
//! including status, findings, and aggregated responses.
55
//!
6+
//! Note: The primary types for validation are re-exported at `cortex_agents::forge`
7+
//! from the `agents` module. This protocol module provides additional types
8+
//! for the orchestrator, including `ForgeResponse` and `Location` types.
9+
//!
610
//! # Example
711
//!
812
//! ```rust
913
//! use cortex_agents::forge::{
10-
//! ValidationStatus, ValidationResult, Finding, Severity, RuleInfo, ForgeResponse,
14+
//! ValidationStatus, ValidationResult, Finding, Severity, RuleInfo,
1115
//! };
12-
//! use chrono::Utc;
1316
//!
14-
//! let result = ValidationResult {
15-
//! status: ValidationStatus::Pass,
16-
//! agent_id: "security-scanner".to_string(),
17-
//! rules_applied: vec![
18-
//! RuleInfo::new("SEC001", "No secrets in code"),
19-
//! ],
20-
//! findings: vec![],
21-
//! timestamp: Utc::now(),
22-
//! };
17+
//! // Create a validation result using the builder pattern
18+
//! let mut result = ValidationResult::new("security-scanner");
19+
//!
20+
//! // Add a finding
21+
//! result.add_finding(Finding::new("SEC001", Severity::Warning, "Potential issue detected"));
2322
//!
24-
//! assert!(result.is_success());
23+
//! // Check result status
24+
//! assert!(result.is_passed()); // No errors or criticals = passed
2525
//! ```
2626
2727
use chrono::{DateTime, Utc};

src/cortex-tui/src/views/forge.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ impl ForgeView {
356356
}
357357

358358
/// Renders the Forge view.
359-
pub fn render(&self, frame: &mut Frame, area: Rect) {
359+
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
360360
// Clear the area
361361
frame.render_widget(Clear, area);
362362

@@ -489,7 +489,7 @@ impl ForgeView {
489489

490490
let duration_str = agent
491491
.duration_ms
492-
.map(|ms| format_duration_ms(ms))
492+
.map(format_duration_ms)
493493
.unwrap_or_else(|| "-".to_string());
494494

495495
Row::new(vec![
@@ -518,7 +518,7 @@ impl ForgeView {
518518
}
519519

520520
/// Renders the findings panel.
521-
fn render_findings_panel(&self, frame: &mut Frame, area: Rect) {
521+
fn render_findings_panel(&mut self, frame: &mut Frame, area: Rect) {
522522
let is_active = self.active_panel == ForgePanel::Findings;
523523
let border_color = if is_active {
524524
Color::Cyan
@@ -546,11 +546,11 @@ impl ForgeView {
546546
// Calculate visible rows
547547
let visible_rows = inner.height as usize;
548548

549-
// Adjust scroll offset to keep selection visible
550-
let scroll_offset = if self.selected_finding < self.scroll_offset {
549+
// Adjust scroll offset to keep selection visible and persist it
550+
self.scroll_offset = if self.selected_finding < self.scroll_offset {
551551
self.selected_finding
552-
} else if self.selected_finding >= self.scroll_offset + visible_rows {
553-
self.selected_finding - visible_rows + 1
552+
} else if visible_rows > 0 && self.selected_finding >= self.scroll_offset + visible_rows {
553+
self.selected_finding.saturating_sub(visible_rows.saturating_sub(1))
554554
} else {
555555
self.scroll_offset
556556
};
@@ -562,7 +562,7 @@ impl ForgeView {
562562
.findings
563563
.iter()
564564
.enumerate()
565-
.skip(scroll_offset)
565+
.skip(self.scroll_offset)
566566
.take(visible_rows)
567567
{
568568
let is_selected = idx == self.selected_finding && is_active;

0 commit comments

Comments
 (0)