Skip to content

Commit 73da0ee

Browse files
haasonsaasclaude
andcommitted
fix: 7 TDD-discovered bugs — glob escaping, severity parsing, confidence normalization, time filters, negative offsets
- interactive.rs: Fix glob-to-regex to escape dots and anchor patterns (*.test.js no longer matches fooAtestBjs or foo.test.js.bak) - smart_response.rs: Accept standard severity names (error/warning/info/suggestion) alongside prompt names (critical/high/medium/low) - smart_response.rs: Treat bare decimals in 0..1 range as already-normalized confidence (0.85 → 85%, not 0.0085) - storage_json.rs: Exclude events with created_at=None when time filters are active (is_none_or → is_some_and) - storage_json.rs: Clamp negative limit/offset to 0 to prevent usize wraparound from negative i64 cast All bugs discovered via TDD: failing test written first, then minimal fix. 977 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d36c276 commit 73da0ee

File tree

3 files changed

+243
-17
lines changed

3 files changed

+243
-17
lines changed

src/core/interactive.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,40 @@ mod tests {
426426
assert!(processor.should_ignore("types.generated.ts"));
427427
assert!(!processor.should_ignore("src/main.rs"));
428428
}
429+
430+
// ── Bug: glob dots are not escaped before regex conversion ──────────
431+
//
432+
// `should_ignore` converts glob patterns to regex by replacing `*`
433+
// with `.*`, but does NOT escape the `.` characters in the pattern.
434+
// As a result, "*.test.js" becomes regex `".*..test..js"` where the
435+
// dots match ANY character, causing false positives.
436+
//
437+
// For example, "fooAtestBjs" matches because the unescaped dots in
438+
// the regex accept any character, not just literal periods.
439+
440+
#[test]
441+
fn test_processor_glob_dot_is_literal() {
442+
let mut processor = InteractiveProcessor::new();
443+
processor.add_ignore_pattern("*.test.js");
444+
// Should match files with literal dots
445+
assert!(processor.should_ignore("foo.test.js"));
446+
// Should NOT match when dots are replaced by other characters
447+
assert!(
448+
!processor.should_ignore("fooAtestBjs"),
449+
"Glob dot should match literal '.' only, not arbitrary characters"
450+
);
451+
}
452+
453+
#[test]
454+
fn test_processor_glob_anchored() {
455+
let mut processor = InteractiveProcessor::new();
456+
processor.add_ignore_pattern("*.test.js");
457+
// Should NOT match files with a suffix after .js
458+
assert!(
459+
!processor.should_ignore("foo.test.js.bak"),
460+
"Glob pattern should not match files with extra suffixes"
461+
);
462+
}
429463
}
430464

431465
/// Manages per-session ignore patterns from @diffscope ignore commands.
@@ -451,7 +485,9 @@ impl InteractiveProcessor {
451485
self.ignored_patterns.iter().any(|pattern| {
452486
// Simple glob matching
453487
if pattern.contains('*') {
454-
let regex_pattern = pattern.replace("*", ".*");
488+
// Escape regex metacharacters, then convert glob * to .*
489+
let escaped = regex::escape(pattern).replace(r"\*", ".*");
490+
let regex_pattern = format!("^{}$", escaped);
455491
regex::Regex::new(&regex_pattern)
456492
.map(|re| re.is_match(path))
457493
.unwrap_or(false)

src/parsing/smart_response.rs

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ fn append_suggestion(suggestion: &mut Option<String>, value: &str) {
143143

144144
pub fn parse_smart_severity(value: &str) -> Option<core::comment::Severity> {
145145
match value.to_lowercase().as_str() {
146-
"critical" => Some(core::comment::Severity::Error),
147-
"high" => Some(core::comment::Severity::Warning),
148-
"medium" => Some(core::comment::Severity::Info),
149-
"low" => Some(core::comment::Severity::Suggestion),
146+
"critical" | "error" => Some(core::comment::Severity::Error),
147+
"high" | "warning" => Some(core::comment::Severity::Warning),
148+
"medium" | "info" => Some(core::comment::Severity::Info),
149+
"low" | "suggestion" => Some(core::comment::Severity::Suggestion),
150150
_ => None,
151151
}
152152
}
@@ -169,9 +169,17 @@ pub fn parse_smart_category(value: &str) -> Option<core::comment::Category> {
169169
}
170170

171171
pub fn parse_smart_confidence(value: &str) -> Option<f32> {
172-
let trimmed = value.trim().trim_end_matches('%');
173-
if let Ok(percent) = trimmed.parse::<f32>() {
174-
Some((percent / 100.0).clamp(0.0, 1.0))
172+
let raw = value.trim();
173+
let has_percent = raw.ends_with('%');
174+
let trimmed = raw.trim_end_matches('%');
175+
if let Ok(num) = trimmed.parse::<f32>() {
176+
if !has_percent && num >= 0.0 && num <= 1.0 {
177+
// Bare decimal in 0..1 range — treat as already-normalized confidence
178+
Some(num.clamp(0.0, 1.0))
179+
} else {
180+
// Percentage value (e.g., "85" or "85%") — divide by 100
181+
Some((num / 100.0).clamp(0.0, 1.0))
182+
}
175183
} else {
176184
None
177185
}
@@ -376,4 +384,68 @@ TAGS: auth, security
376384
let tags = parse_smart_tags("auth,,, security");
377385
assert_eq!(tags, vec!["auth", "security"]);
378386
}
387+
388+
// ── Bug: parse_smart_severity rejects standard severity names ────────
389+
//
390+
// The prompt instructs the LLM to use CRITICAL|HIGH|MEDIUM|LOW, but
391+
// parse_rule_severity_override (in rule_helpers.rs) accepts both name
392+
// families. LLMs frequently output the canonical Severity enum names
393+
// (e.g. "SEVERITY: Error" or "SEVERITY: Warning") and those silently
394+
// become None, losing severity information.
395+
396+
#[test]
397+
fn parse_smart_severity_accepts_standard_names() {
398+
// "error" / "warning" / "info" / "suggestion" are the enum names
399+
// used everywhere else in the codebase and are commonly output by
400+
// LLMs. parse_smart_severity should accept them.
401+
assert_eq!(
402+
parse_smart_severity("error"),
403+
Some(core::comment::Severity::Error),
404+
"\"error\" should map to Severity::Error"
405+
);
406+
assert_eq!(
407+
parse_smart_severity("warning"),
408+
Some(core::comment::Severity::Warning),
409+
"\"warning\" should map to Severity::Warning"
410+
);
411+
assert_eq!(
412+
parse_smart_severity("info"),
413+
Some(core::comment::Severity::Info),
414+
"\"info\" should map to Severity::Info"
415+
);
416+
assert_eq!(
417+
parse_smart_severity("suggestion"),
418+
Some(core::comment::Severity::Suggestion),
419+
"\"suggestion\" should map to Severity::Suggestion"
420+
);
421+
}
422+
423+
// ── Bug: parse_smart_confidence misinterprets bare decimals ──────────
424+
//
425+
// When an LLM outputs "CONFIDENCE: 0.85" (a bare float instead of
426+
// "85%"), parse_smart_confidence divides by 100 producing 0.0085,
427+
// which effectively discards every comment at the default confidence
428+
// threshold. Values <= 1.0 without a '%' suffix should be treated
429+
// as already-normalized (0.0..1.0).
430+
431+
#[test]
432+
fn parse_smart_confidence_bare_decimal_treated_as_fraction() {
433+
// 0.85 is clearly a 0..1 confidence, not 0.85%
434+
let conf = parse_smart_confidence("0.85").unwrap();
435+
assert!(
436+
(conf - 0.85).abs() < 0.001,
437+
"0.85 should be treated as 85% confidence, got {}",
438+
conf
439+
);
440+
}
441+
442+
#[test]
443+
fn parse_smart_confidence_bare_decimal_zero_point_five() {
444+
let conf = parse_smart_confidence("0.5").unwrap();
445+
assert!(
446+
(conf - 0.5).abs() < 0.001,
447+
"0.5 should be treated as 50% confidence, got {}",
448+
conf
449+
);
450+
}
379451
}

src/server/storage_json.rs

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ impl StorageBackend for JsonStorageBackend {
105105
let reviews = self.reviews.read().await;
106106
let mut list: Vec<&ReviewSession> = reviews.values().collect();
107107
list.sort_by(|a, b| b.started_at.cmp(&a.started_at));
108-
let offset = offset as usize;
109-
let limit = limit as usize;
108+
let offset = offset.max(0) as usize;
109+
let limit = limit.max(0) as usize;
110110
Ok(list.into_iter().skip(offset).take(limit).cloned().collect())
111111
}
112112

@@ -142,15 +142,16 @@ impl StorageBackend for JsonStorageBackend {
142142
.status
143143
.as_ref()
144144
.is_none_or(|f| e.event_type.eq_ignore_ascii_case(&format!("review.{}", f)));
145-
// Time filters (best-effort for JSON backend using created_at if available)
145+
// Time filters: if a time bound is specified, events without a
146+
// timestamp are excluded (they cannot satisfy the constraint).
146147
let time_from_ok = filters
147148
.time_from
148149
.as_ref()
149-
.is_none_or(|from| e.created_at.is_none_or(|t| t >= *from));
150+
.is_none_or(|from| e.created_at.is_some_and(|t| t >= *from));
150151
let time_to_ok = filters
151152
.time_to
152153
.as_ref()
153-
.is_none_or(|to| e.created_at.is_none_or(|t| t <= *to));
154+
.is_none_or(|to| e.created_at.is_some_and(|t| t <= *to));
154155
let repo_ok = filters
155156
.github_repo
156157
.as_ref()
@@ -167,8 +168,8 @@ impl StorageBackend for JsonStorageBackend {
167168
});
168169

169170
// Apply limit/offset
170-
let offset = filters.offset.unwrap_or(0) as usize;
171-
let limit = filters.limit.unwrap_or(500) as usize;
171+
let offset = filters.offset.unwrap_or(0).max(0) as usize;
172+
let limit = filters.limit.unwrap_or(500).max(0) as usize;
172173
events = events.into_iter().skip(offset).take(limit).collect();
173174

174175
Ok(events)
@@ -177,8 +178,8 @@ impl StorageBackend for JsonStorageBackend {
177178
async fn get_event_stats(&self, filters: &EventFilters) -> anyhow::Result<EventStats> {
178179
// Stats must cover ALL matching events, not a paginated subset
179180
let mut stats_filters = filters.clone();
180-
stats_filters.limit = None;
181-
stats_filters.offset = None;
181+
stats_filters.limit = Some(i64::MAX);
182+
stats_filters.offset = Some(0);
182183
let events = self.list_events(&stats_filters).await?;
183184

184185
let total = events.len() as i64;
@@ -1497,6 +1498,123 @@ mod tests {
14971498
assert_eq!(events[0].github_repo.as_deref(), Some("owner/repo-a"));
14981499
}
14991500

1501+
// ── Bug: time filters include events with created_at = None ──────
1502+
//
1503+
// When a time_from or time_to filter is active, events whose
1504+
// `created_at` is None should be *excluded* (they have no timestamp
1505+
// to satisfy the constraint). Previously `is_none_or` let them
1506+
// through.
1507+
1508+
#[tokio::test]
1509+
async fn test_time_filter_excludes_events_without_timestamp() {
1510+
let dir = tempfile::tempdir().unwrap();
1511+
let backend = JsonStorageBackend::new(&dir.path().join("reviews.json"));
1512+
let now = now_ts();
1513+
1514+
// Event WITH a timestamp (via build())
1515+
let s1 = make_session_with_event(
1516+
"r1",
1517+
now,
1518+
ReviewStatus::Complete,
1519+
"review.completed",
1520+
"gpt-4o",
1521+
"github",
1522+
100,
1523+
);
1524+
backend.save_review(&s1).await.unwrap();
1525+
1526+
// Event WITHOUT a timestamp (manually set created_at = None)
1527+
let mut s2 = make_session_with_event(
1528+
"r2",
1529+
now + 1,
1530+
ReviewStatus::Complete,
1531+
"review.completed",
1532+
"gpt-4o",
1533+
"github",
1534+
200,
1535+
);
1536+
s2.event.as_mut().unwrap().created_at = None;
1537+
backend.save_review(&s2).await.unwrap();
1538+
1539+
// Filter with time_from = epoch (should match everything WITH a ts)
1540+
let filters = EventFilters {
1541+
time_from: Some(chrono::DateTime::from_timestamp(0, 0).unwrap()),
1542+
..Default::default()
1543+
};
1544+
let events = backend.list_events(&filters).await.unwrap();
1545+
assert_eq!(
1546+
events.len(),
1547+
1,
1548+
"Events with created_at = None should be excluded by time filters, got {}",
1549+
events.len()
1550+
);
1551+
assert_eq!(events[0].review_id, "r1");
1552+
}
1553+
1554+
// ── Bug: negative limit/offset wraps to huge usize ───────────────
1555+
//
1556+
// Casting a negative i64 directly to usize wraps to a very large
1557+
// number, causing list_reviews to skip/take billions of entries.
1558+
1559+
#[tokio::test]
1560+
async fn test_list_reviews_negative_offset_does_not_panic() {
1561+
let dir = tempfile::tempdir().unwrap();
1562+
let backend = JsonStorageBackend::new(&dir.path().join("reviews.json"));
1563+
let now = now_ts();
1564+
1565+
backend
1566+
.save_review(&make_session("r1", now, ReviewStatus::Complete))
1567+
.await
1568+
.unwrap();
1569+
1570+
// Negative offset and limit should not panic or return nonsense
1571+
let result = backend.list_reviews(10, -1).await.unwrap();
1572+
assert_eq!(result.len(), 1, "Negative offset should be clamped to 0");
1573+
1574+
let result = backend.list_reviews(-1, 0).await.unwrap();
1575+
assert!(
1576+
result.is_empty(),
1577+
"Negative limit should be clamped to 0, returning no results"
1578+
);
1579+
}
1580+
1581+
#[tokio::test]
1582+
async fn test_list_events_negative_offset_does_not_panic() {
1583+
let dir = tempfile::tempdir().unwrap();
1584+
let backend = JsonStorageBackend::new(&dir.path().join("reviews.json"));
1585+
let now = now_ts();
1586+
1587+
let s1 = make_session_with_event(
1588+
"r1",
1589+
now,
1590+
ReviewStatus::Complete,
1591+
"review.completed",
1592+
"gpt-4o",
1593+
"github",
1594+
100,
1595+
);
1596+
backend.save_review(&s1).await.unwrap();
1597+
1598+
let filters = EventFilters {
1599+
offset: Some(-5),
1600+
limit: Some(100),
1601+
..Default::default()
1602+
};
1603+
let events = backend.list_events(&filters).await.unwrap();
1604+
assert_eq!(events.len(), 1, "Negative offset should be clamped to 0");
1605+
1606+
let filters = EventFilters {
1607+
offset: Some(0),
1608+
limit: Some(-10),
1609+
..Default::default()
1610+
};
1611+
let events = backend.list_events(&filters).await.unwrap();
1612+
assert!(
1613+
events.is_empty(),
1614+
"Negative limit should be clamped to 0, returning no results"
1615+
);
1616+
}
1617+
15001618
#[tokio::test]
15011619
async fn test_prune_persists_to_disk() {
15021620
// BUG: prune removes from memory but doesn't flush to disk

0 commit comments

Comments
 (0)