Skip to content

Commit 9fee38f

Browse files
committed
feat(analyzer): integrate AstDiffer into symbol extraction pipeline
Run structural diffing inside extract_for_file() while both old and new AST trees are alive. Modified symbols matched by (kind, name, file) get a SymbolDiff with parameter, return type, visibility, async, and body change details. Results flow through ContextBuilder to PromptContext.
1 parent cb1db93 commit 9fee38f

8 files changed

Lines changed: 243 additions & 130 deletions

File tree

src/app.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ impl App {
178178
let (staged_map, head_map) = git.fetch_file_contents(&file_paths).await;
179179

180180
// Parse symbols in parallel across CPU cores (rayon)
181-
let symbols = analyzer.extract_symbols(&changes.files, &staged_map, &head_map);
181+
let (symbols, symbol_diffs) =
182+
analyzer.extract_symbols(&changes.files, &staged_map, &head_map);
182183

183184
debug!(count = symbols.len(), "symbols extracted");
184185

@@ -201,7 +202,9 @@ impl App {
201202
.interact()?;
202203

203204
if split_confirm {
204-
return self.run_split_flow(&git, groups, &changes, &symbols).await;
205+
return self
206+
.run_split_flow(&git, groups, &changes, &symbols, &symbol_diffs)
207+
.await;
205208
}
206209
progress.info("Proceeding with single commit");
207210
}
@@ -232,7 +235,7 @@ impl App {
232235
};
233236

234237
// Step 4: Build context
235-
let mut context = ContextBuilder::build(&changes, &symbols, &self.config);
238+
let mut context = ContextBuilder::build(&changes, &symbols, &symbol_diffs, &self.config);
236239
context.history_context = history_prompt;
237240
debug!(prompt_chars = context.to_prompt().len(), "context built");
238241

@@ -556,6 +559,7 @@ impl App {
556559
groups: Vec<crate::services::splitter::CommitGroup>,
557560
changes: &StagedChanges,
558561
symbols: &[CodeSymbol],
562+
symbol_diffs: &[crate::domain::diff::SymbolDiff],
559563
) -> Result<()> {
560564
// Safety: check for files with both staged and unstaged changes
561565
let overlap = git.has_unstaged_overlap().await?;
@@ -608,7 +612,13 @@ impl App {
608612
.cloned()
609613
.collect();
610614

611-
let mut context = ContextBuilder::build(&sub_changes, &sub_symbols, &self.config);
615+
let sub_diffs: Vec<_> = symbol_diffs
616+
.iter()
617+
.filter(|d| sub_changes.files.iter().any(|f| f.path == d.file))
618+
.cloned()
619+
.collect();
620+
let mut context =
621+
ContextBuilder::build(&sub_changes, &sub_symbols, &sub_diffs, &self.config);
612622
context.group_rationale = Some(Self::infer_group_rationale(
613623
&sub_changes,
614624
&group.commit_type,

src/domain/context.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
44

55
use super::CommitType;
6+
use super::diff::SymbolDiff;
67

78
#[derive(Debug)]
89
pub struct PromptContext {
@@ -42,6 +43,10 @@ pub struct PromptContext {
4243
/// Source-to-test file correlations detected from staged changes.
4344
/// e.g., "src/services/context.rs <-> tests/context.rs (test file)"
4445
pub test_correlations: Vec<String>,
46+
/// Structured semantic changes for modified symbols (from AstDiffer).
47+
/// Used by the prompt formatter in T1-4 to show semantic diffs to the LLM.
48+
#[allow(dead_code)]
49+
pub structured_changes: Vec<SymbolDiff>,
4550
}
4651

4752
impl PromptContext {

src/eval.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ impl EvalRunner {
412412
let symbols = self.load_symbols(fixture_dir);
413413

414414
// Run context builder with injected symbols
415-
let context = ContextBuilder::build(&changes, &symbols, &config);
415+
let context = ContextBuilder::build(&changes, &symbols, &[], &config);
416416

417417
// Check type inference
418418
let actual_type = context.suggested_type.as_str().to_string();

src/services/analyzer.rs

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ use rayon::prelude::*;
1010
use regex::Regex;
1111
use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
1212

13+
use crate::domain::diff::SymbolDiff;
1314
use crate::domain::{CodeSymbol, FileChange, SymbolKind};
1415
use crate::error::Result;
16+
use crate::services::differ::AstDiffer;
1517

1618
// ─── Embedded query patterns ────────────────────────────────────────────────
1719

@@ -142,11 +144,11 @@ impl AnalyzerService {
142144
changes: &[FileChange],
143145
staged_content: &HashMap<PathBuf, String>,
144146
head_content: &HashMap<PathBuf, String>,
145-
) -> Vec<CodeSymbol> {
146-
changes
147+
) -> (Vec<CodeSymbol>, Vec<SymbolDiff>) {
148+
let pairs: Vec<(Vec<CodeSymbol>, Vec<SymbolDiff>)> = changes
147149
.par_iter()
148150
.filter(|change| !change.is_binary)
149-
.flat_map(|change| {
151+
.map(|change| {
150152
let ext = change
151153
.path
152154
.extension()
@@ -224,7 +226,15 @@ impl AnalyzerService {
224226
})
225227
.unwrap_or_default()
226228
})
227-
.collect()
229+
.collect();
230+
231+
let mut all_symbols = Vec::new();
232+
let mut all_diffs = Vec::new();
233+
for (syms, diffs) in pairs {
234+
all_symbols.extend(syms);
235+
all_diffs.extend(diffs);
236+
}
237+
(all_symbols, all_diffs)
228238
}
229239

230240
fn extract_for_file(
@@ -233,17 +243,18 @@ impl AnalyzerService {
233243
hunks: &[DiffHunk],
234244
staged_content: &HashMap<PathBuf, String>,
235245
head_content: &HashMap<PathBuf, String>,
236-
) -> Vec<CodeSymbol> {
246+
) -> (Vec<CodeSymbol>, Vec<SymbolDiff>) {
237247
let mut parser = Parser::new();
238248
if parser.set_language(&config.language).is_err() {
239-
return Vec::new();
249+
return (Vec::new(), Vec::new());
240250
}
241251

242252
let Ok(query) = Query::new(&config.language, config.query_source) else {
243-
return Vec::new();
253+
return (Vec::new(), Vec::new());
244254
};
245255

246-
let mut symbols = Vec::new();
256+
let mut staged_symbols = Vec::new();
257+
let mut head_symbols = Vec::new();
247258

248259
// Parse staged (new) file content
249260
if let Some(content) = staged_content.get(&change.path) {
@@ -256,7 +267,7 @@ impl AnalyzerService {
256267
hunks,
257268
true,
258269
);
259-
symbols.extend(changed);
270+
staged_symbols = changed;
260271
}
261272

262273
// Parse HEAD (old) file content
@@ -270,10 +281,92 @@ impl AnalyzerService {
270281
hunks,
271282
false,
272283
);
273-
symbols.extend(changed);
284+
head_symbols = changed;
274285
}
275286

276-
symbols
287+
// Run AstDiffer on modified symbols (F-002: must run while Trees are alive)
288+
let mut diffs = Vec::new();
289+
if let (Some(staged_src), Some(head_src)) = (
290+
staged_content.get(&change.path),
291+
head_content.get(&change.path),
292+
) {
293+
let mut diff_parser = Parser::new();
294+
if diff_parser.set_language(&config.language).is_ok()
295+
&& let (Some(staged_tree), Some(head_tree)) = (
296+
diff_parser.parse(staged_src.as_str(), None),
297+
diff_parser.parse(head_src.as_str(), None),
298+
)
299+
{
300+
for staged_sym in &staged_symbols {
301+
if let Some(head_sym) = head_symbols
302+
.iter()
303+
.find(|h| h.kind == staged_sym.kind && h.name == staged_sym.name)
304+
&& let (Some(new_node), Some(old_node)) = (
305+
Self::find_node_at_line(&staged_tree, staged_sym.line),
306+
Self::find_node_at_line(&head_tree, head_sym.line),
307+
)
308+
{
309+
let is_function =
310+
matches!(staged_sym.kind, SymbolKind::Function | SymbolKind::Method);
311+
let changes = if is_function {
312+
AstDiffer::diff_function(old_node, head_src, new_node, staged_src)
313+
} else {
314+
AstDiffer::diff_struct(old_node, head_src, new_node, staged_src)
315+
};
316+
if !changes.is_empty() {
317+
diffs.push(SymbolDiff {
318+
name: staged_sym.name.clone(),
319+
file: staged_sym.file.clone(),
320+
line: staged_sym.line,
321+
parent_scope: staged_sym.parent_scope.clone(),
322+
changes,
323+
});
324+
}
325+
}
326+
}
327+
}
328+
}
329+
330+
let mut all_symbols = staged_symbols;
331+
all_symbols.extend(head_symbols);
332+
(all_symbols, diffs)
333+
}
334+
335+
/// Find a definition node (function/struct/etc) at the given 1-based line number.
336+
/// Recurses into container nodes (impl blocks, class bodies) to find methods.
337+
fn find_node_at_line(tree: &tree_sitter::Tree, line: usize) -> Option<tree_sitter::Node<'_>> {
338+
let root = tree.root_node();
339+
let target_row = line.saturating_sub(1); // tree-sitter uses 0-based rows
340+
Self::find_node_at_row(root, target_row)
341+
}
342+
343+
fn find_node_at_row(
344+
parent: tree_sitter::Node<'_>,
345+
target_row: usize,
346+
) -> Option<tree_sitter::Node<'_>> {
347+
for i in 0..parent.child_count() {
348+
#[allow(clippy::cast_possible_truncation)]
349+
if let Some(child) = parent.child(i as u32)
350+
&& child.start_position().row <= target_row
351+
&& child.end_position().row >= target_row
352+
{
353+
// If this is a container (impl, class, etc.), recurse into it
354+
if matches!(
355+
child.kind(),
356+
"impl_item"
357+
| "class_declaration"
358+
| "class_definition"
359+
| "declaration_list"
360+
| "class_body"
361+
| "interface_body"
362+
) && let Some(inner) = Self::find_node_at_row(child, target_row)
363+
{
364+
return Some(inner);
365+
}
366+
return Some(child);
367+
}
368+
}
369+
None
277370
}
278371

279372
fn extract_changed_symbols_with_query(

src/services/context.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use std::collections::HashSet;
66

77
use crate::config::Config;
8+
use crate::domain::diff::SymbolDiff;
89
use crate::domain::{
910
ChangeStatus, CodeSymbol, CommitType, FileCategory, PromptContext, SpanChangeKind,
1011
StagedChanges, SymbolKind,
@@ -38,6 +39,7 @@ impl ContextBuilder {
3839
pub fn build(
3940
changes: &StagedChanges,
4041
symbols: &[CodeSymbol],
42+
diffs: &[SymbolDiff],
4143
config: &Config,
4244
) -> PromptContext {
4345
// Build components with budget management
@@ -231,6 +233,7 @@ impl ContextBuilder {
231233
connections: Self::detect_connections(changes, symbols),
232234
import_changes: Self::detect_import_changes(changes),
233235
test_correlations: Self::detect_test_correlation(changes),
236+
structured_changes: diffs.to_vec(),
234237
}
235238
}
236239

0 commit comments

Comments
 (0)