Skip to content

Commit f2535bc

Browse files
committed
feat(context): detect import/use statement changes in diff
Add IMPORTS CHANGED: section to the LLM prompt showing added/removed import lines. Supports Rust use, JS/TS/Python import, Node require, and C/C++ #include. Capped at 10 entries.
1 parent 093b01e commit f2535bc

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

src/domain/context.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub struct PromptContext {
3636
/// Cross-symbol relationships detected from diff content.
3737
/// e.g., "validate calls parse() — both changed"
3838
pub connections: Vec<String>,
39+
/// Import/use statement changes detected from diff.
40+
/// e.g., "analyzer: added use crate::domain::DiffHunk"
41+
pub import_changes: Vec<String>,
3942
}
4043

4144
impl PromptContext {
@@ -58,6 +61,19 @@ impl PromptContext {
5861
)
5962
};
6063

64+
let imports_section = if self.import_changes.is_empty() {
65+
String::new()
66+
} else {
67+
format!(
68+
"\nIMPORTS CHANGED:\n{}\n",
69+
self.import_changes
70+
.iter()
71+
.map(|i| format!(" {}", i))
72+
.collect::<Vec<_>>()
73+
.join("\n")
74+
)
75+
};
76+
6177
// Calculate available chars for subject after type(scope)[!]: prefix
6278
let prefix_len = self.suggested_type.as_str().len()
6379
+ self
@@ -123,7 +139,7 @@ impl PromptContext {
123139
SUMMARY: {summary}
124140
FILES: {files}
125141
SUGGESTED TYPE: {commit_type}{scope}
126-
{group_rationale}{evidence}{primary_change}{symbols}{connections}
142+
{group_rationale}{evidence}{primary_change}{symbols}{connections}{imports}
127143
DIFF:
128144
{diff}
129145
{constraints}{breaking}{metadata_breaking}{locale}{focus}{history}
@@ -143,6 +159,7 @@ Respond with ONLY this JSON:
143159
evidence = evidence_section,
144160
symbols = symbols_section,
145161
connections = connections_section,
162+
imports = imports_section,
146163
breaking = breaking_warning,
147164
constraints = constraints_section,
148165
focus = focus_instruction,

src/services/context.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ impl ContextBuilder {
212212
locale: config.locale.clone(),
213213
history_context: None, // Set by App when learn_from_history is enabled
214214
connections: Self::detect_connections(changes, symbols),
215+
import_changes: Self::detect_import_changes(changes),
215216
}
216217
}
217218

@@ -813,6 +814,49 @@ impl ContextBuilder {
813814
connections
814815
}
815816

817+
/// Detect added/removed import statements from diff lines.
818+
fn detect_import_changes(changes: &StagedChanges) -> Vec<String> {
819+
let mut imports = Vec::new();
820+
821+
for file in &changes.files {
822+
for line in file.diff.lines() {
823+
// Skip diff headers
824+
if line.starts_with("+++") || line.starts_with("---") {
825+
continue;
826+
}
827+
// Detect added/removed import lines
828+
if (line.starts_with('+') && Self::is_import_line(&line[1..]))
829+
|| (line.starts_with('-') && Self::is_import_line(&line[1..]))
830+
{
831+
let action = if line.starts_with('+') {
832+
"added"
833+
} else {
834+
"removed"
835+
};
836+
let stem = file
837+
.path
838+
.file_stem()
839+
.and_then(|s| s.to_str())
840+
.unwrap_or("?");
841+
let content = line[1..].trim();
842+
imports.push(format!("{}: {} {}", stem, action, content));
843+
}
844+
}
845+
}
846+
847+
imports.truncate(10); // Cap to avoid prompt bloat
848+
imports
849+
}
850+
851+
fn is_import_line(line: &str) -> bool {
852+
let trimmed = line.trim();
853+
trimmed.starts_with("use ")
854+
|| trimmed.starts_with("import ")
855+
|| trimmed.starts_with("from ")
856+
|| trimmed.starts_with("require(")
857+
|| trimmed.starts_with("#include") // C/C++
858+
}
859+
816860
/// Scan diff content for metadata changes that indicate breaking changes.
817861
///
818862
/// Detects: MSRV bumps, minimum engine/runtime version raises, removed features/exports.

tests/context.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,6 +1436,85 @@ fn whitespace_detection_returns_none_when_span_has_no_changes() {
14361436
);
14371437
}
14381438

1439+
// ─── Import change detection ─────────────────────────────────────────────────
1440+
1441+
#[test]
1442+
fn detect_rust_import_changes() {
1443+
let changes = make_staged_changes(vec![make_file_change(
1444+
"src/analyzer.rs",
1445+
ChangeStatus::Modified,
1446+
"+use crate::domain::DiffHunk;\n-use crate::old_module::OldType;\n context line",
1447+
1,
1448+
1,
1449+
)]);
1450+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1451+
assert_eq!(ctx.import_changes.len(), 2);
1452+
assert!(ctx.import_changes[0].contains("added"));
1453+
assert!(ctx.import_changes[0].contains("use crate::domain::DiffHunk"));
1454+
assert!(ctx.import_changes[1].contains("removed"));
1455+
}
1456+
1457+
#[test]
1458+
fn detect_python_import_changes() {
1459+
let changes = make_staged_changes(vec![make_file_change(
1460+
"app/main.py",
1461+
ChangeStatus::Modified,
1462+
"+from flask import Blueprint\n+import os\n",
1463+
2,
1464+
0,
1465+
)]);
1466+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1467+
assert_eq!(ctx.import_changes.len(), 2);
1468+
}
1469+
1470+
#[test]
1471+
fn detect_cpp_include_changes() {
1472+
let changes = make_staged_changes(vec![make_file_change(
1473+
"src/main.cpp",
1474+
ChangeStatus::Modified,
1475+
"+#include <vector>\n-#include <list>\n",
1476+
1,
1477+
1,
1478+
)]);
1479+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1480+
assert_eq!(ctx.import_changes.len(), 2);
1481+
assert!(ctx.import_changes[0].contains("#include <vector>"));
1482+
}
1483+
1484+
#[test]
1485+
fn import_changes_capped_at_10() {
1486+
let diff: String = (0..15)
1487+
.map(|i| format!("+use crate::mod_{i}::Type;\n"))
1488+
.collect();
1489+
let changes = make_staged_changes(vec![make_file_change(
1490+
"src/lib.rs",
1491+
ChangeStatus::Modified,
1492+
&diff,
1493+
15,
1494+
0,
1495+
)]);
1496+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1497+
assert_eq!(ctx.import_changes.len(), 10);
1498+
}
1499+
1500+
#[test]
1501+
fn import_changes_shown_in_prompt() {
1502+
let changes = make_staged_changes(vec![make_file_change(
1503+
"src/lib.rs",
1504+
ChangeStatus::Modified,
1505+
"+use crate::new_dep::Thing;\n",
1506+
1,
1507+
0,
1508+
)]);
1509+
let ctx = ContextBuilder::build(&changes, &[], &default_config());
1510+
let prompt = ctx.to_prompt();
1511+
assert!(
1512+
prompt.contains("IMPORTS CHANGED:"),
1513+
"prompt should contain imports section"
1514+
);
1515+
assert!(prompt.contains("use crate::new_dep::Thing"));
1516+
}
1517+
14391518
// ─── Test Coverage: HARD LIMIT dedup check (#28) ─────────────────────────────
14401519

14411520
#[test]

0 commit comments

Comments
 (0)