Skip to content

Commit e45cffb

Browse files
committed
feat(context): detect source-to-test file correlations in staged changes
Surfaces source-to-test file relationships in the prompt so the LLM can reason about whether implementation and test changes are paired. Correlations are matched by file stem and capped at 5 entries to avoid noise.
1 parent 4b9f9cd commit e45cffb

3 files changed

Lines changed: 126 additions & 1 deletion

File tree

src/domain/context.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ pub struct PromptContext {
3939
/// Import/use statement changes detected from diff.
4040
/// e.g., "analyzer: added use crate::domain::DiffHunk"
4141
pub import_changes: Vec<String>,
42+
/// Source-to-test file correlations detected from staged changes.
43+
/// e.g., "src/services/context.rs <-> tests/context.rs (test file)"
44+
pub test_correlations: Vec<String>,
4245
}
4346

4447
impl PromptContext {
@@ -74,6 +77,19 @@ impl PromptContext {
7477
)
7578
};
7679

80+
let related_section = if self.test_correlations.is_empty() {
81+
String::new()
82+
} else {
83+
format!(
84+
"\nRELATED FILES:\n{}\n",
85+
self.test_correlations
86+
.iter()
87+
.map(|c| format!(" {}", c))
88+
.collect::<Vec<_>>()
89+
.join("\n")
90+
)
91+
};
92+
7793
// Calculate available chars for subject after type(scope)[!]: prefix
7894
let prefix_len = self.suggested_type.as_str().len()
7995
+ self
@@ -139,7 +155,7 @@ impl PromptContext {
139155
SUMMARY: {summary}
140156
FILES: {files}
141157
SUGGESTED TYPE: {commit_type}{scope}
142-
{group_rationale}{evidence}{primary_change}{symbols}{connections}{imports}
158+
{group_rationale}{evidence}{primary_change}{symbols}{connections}{imports}{related}
143159
DIFF:
144160
{diff}
145161
{constraints}{breaking}{metadata_breaking}{locale}{focus}{history}
@@ -160,6 +176,7 @@ Respond with ONLY this JSON:
160176
symbols = symbols_section,
161177
connections = connections_section,
162178
imports = imports_section,
179+
related = related_section,
163180
breaking = breaking_warning,
164181
constraints = constraints_section,
165182
focus = focus_instruction,

src/services/context.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ impl ContextBuilder {
230230
history_context: None, // Set by App when learn_from_history is enabled
231231
connections: Self::detect_connections(changes, symbols),
232232
import_changes: Self::detect_import_changes(changes),
233+
test_correlations: Self::detect_test_correlation(changes),
233234
}
234235
}
235236

@@ -997,6 +998,37 @@ impl ContextBuilder {
997998
imports
998999
}
9991000

1001+
/// Detect source-to-test file relationships among staged changes.
1002+
fn detect_test_correlation(changes: &StagedChanges) -> Vec<String> {
1003+
let mut correlations = Vec::new();
1004+
let source_files: Vec<_> = changes
1005+
.files
1006+
.iter()
1007+
.filter(|f| f.category == FileCategory::Source)
1008+
.collect();
1009+
let test_files: Vec<_> = changes
1010+
.files
1011+
.iter()
1012+
.filter(|f| f.category == FileCategory::Test)
1013+
.collect();
1014+
1015+
for src in &source_files {
1016+
let src_stem = src.path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
1017+
for test in &test_files {
1018+
let test_stem = test.path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
1019+
if test_stem == src_stem || test_stem.starts_with(src_stem) {
1020+
correlations.push(format!(
1021+
"{} <-> {} (test file)",
1022+
src.path.display(),
1023+
test.path.display()
1024+
));
1025+
}
1026+
}
1027+
}
1028+
correlations.truncate(5);
1029+
correlations
1030+
}
1031+
10001032
fn is_import_line(line: &str) -> bool {
10011033
let trimmed = line.trim();
10021034
trimmed.starts_with("use ")

tests/context.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,6 +1670,82 @@ fn semantic_only_change_has_no_suffix() {
16701670
);
16711671
}
16721672

1673+
// ─── Test file correlation detection ──────────────────────────────────────────
1674+
1675+
#[test]
1676+
fn detect_test_file_correlation() {
1677+
let changes = make_staged_changes(vec![
1678+
make_file_change(
1679+
"src/services/context.rs",
1680+
ChangeStatus::Modified,
1681+
"+code\n",
1682+
1,
1683+
0,
1684+
),
1685+
make_file_change("tests/context.rs", ChangeStatus::Modified, "+test\n", 1, 0),
1686+
]);
1687+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1688+
assert_eq!(ctx.test_correlations.len(), 1);
1689+
assert!(ctx.test_correlations[0].contains("context"));
1690+
}
1691+
1692+
#[test]
1693+
fn no_correlation_without_matching_test() {
1694+
let changes = make_staged_changes(vec![
1695+
make_file_change(
1696+
"src/services/context.rs",
1697+
ChangeStatus::Modified,
1698+
"+code\n",
1699+
1,
1700+
0,
1701+
),
1702+
make_file_change("tests/other.rs", ChangeStatus::Modified, "+test\n", 1, 0),
1703+
]);
1704+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1705+
assert!(ctx.test_correlations.is_empty());
1706+
}
1707+
1708+
#[test]
1709+
fn test_correlation_shown_in_prompt() {
1710+
let changes = make_staged_changes(vec![
1711+
make_file_change(
1712+
"src/services/context.rs",
1713+
ChangeStatus::Modified,
1714+
"+code\n",
1715+
1,
1716+
0,
1717+
),
1718+
make_file_change("tests/context.rs", ChangeStatus::Modified, "+test\n", 1, 0),
1719+
]);
1720+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1721+
let prompt = ctx.to_prompt();
1722+
assert!(prompt.contains("RELATED FILES:"));
1723+
}
1724+
1725+
#[test]
1726+
fn test_correlations_capped_at_5() {
1727+
let mut files = Vec::new();
1728+
for i in 0..8 {
1729+
files.push(make_file_change(
1730+
&format!("src/mod{i}.rs"),
1731+
ChangeStatus::Modified,
1732+
"+code\n",
1733+
1,
1734+
0,
1735+
));
1736+
files.push(make_file_change(
1737+
&format!("tests/mod{i}.rs"),
1738+
ChangeStatus::Modified,
1739+
"+test\n",
1740+
1,
1741+
0,
1742+
));
1743+
}
1744+
let changes = make_staged_changes(files);
1745+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1746+
assert!(ctx.test_correlations.len() <= 5);
1747+
}
1748+
16731749
#[test]
16741750
fn python_comment_only_change_classified_as_docs() {
16751751
let changes = make_staged_changes(vec![make_file_change(

0 commit comments

Comments
 (0)