Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Three levels of semantics, each building on the last:

1. **Structural** (free, deterministic) — builds a symbol graph from tree-sitter ASTs, detects entrypoints (HTTP routes, CLI commands, queue consumers, Effect.ts services, etc.), clusters changed files into flow groups via forward reachability, traces data flow across call chains.
2. **Heuristic** (free, deterministic) — framework detection (Express, Next.js, FastAPI, Effect.ts, 30+ frameworks), risk scoring, review ordering by composite score (risk/centrality/surface-area/uncertainty).
3. **LLM refinement** (paid, optional) — Anthropic, OpenAI, or Gemini reads the actual diff content and refines groupings: split coincidental coupling, merge scattered refactors, re-rank by semantic review order, reclassify misplaced files. Evaluator-optimizer loop scores v1 vs v2, keeps whichever is better.
3. **LLM refinement** (paid, optional) — Anthropic, OpenAI, or Gemini reads the actual diff content and refines groupings: split coincidental coupling, merge scattered refactors, re-rank by semantic review order, reclassify misplaced files. Applied as structural patch operations over the deterministic grouping; on failure the deterministic groups are kept unchanged.

## Target Grouping Strategy

Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ model = "default"
enabled = true
provider = "claude"
model = "default"
max_iterations = 1
```

Example repo-local config:
Expand Down
139 changes: 135 additions & 4 deletions crates/diffcore-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,14 @@ struct AnalyzeArgs {
#[arg(short, long)]
output: Option<PathBuf>,

/// Enable LLM annotation (Pass 1: overview)
/// Enable LLM annotation (Pass 1: PR-level overview)
#[arg(long)]
annotate: bool,

/// Enable the LLM group metadata pass (description, risk, impact, invariant, ...)
#[arg(long)]
describe: bool,

/// Enable LLM refinement pass (overrides config)
#[arg(long)]
refine: bool,
Expand Down Expand Up @@ -510,7 +514,12 @@ fn run_analyze(mut args: AnalyzeArgs) -> Result<(), Box<dyn std::error::Error>>
// or if --no-cache was passed). Cache key incorporates ignore patterns so any
// ignore-config change (incl. auto-detected subtrees) invalidates the entry.
let cache_key = cache::compute_cache_key(&diff_result, &config.ignore.paths);
if !args.annotate && !args.refine && args.refine_model.is_none() && !args.no_cache {
if !args.annotate
&& !args.refine
&& !args.describe
&& args.refine_model.is_none()
&& !args.no_cache
{
if let Some(cached) = cache::load_cached(&workdir, &cache_key) {
return write_output(&cached, args.output.as_deref());
}
Expand Down Expand Up @@ -598,6 +607,32 @@ fn run_analyze(mut args: AnalyzeArgs) -> Result<(), Box<dyn std::error::Error>>
}
}

// Refinement rewrites group composition without re-scoring, so the ranking
// score and the heuristic metadata derived from it are both stale by here.
// Both have to be recomputed before the metadata pass runs on top.
rank::rescore_groups(&mut analysis_output.groups, &file_centrality, &weights);
diffcore_core::group_metadata::apply_heuristic_metadata(&mut analysis_output.groups);

// Apply the LLM group metadata pass if requested. Explicit flag only —
// `diffcore analyze` in CI must never start billing silently.
if args.describe {
let rt = tokio::runtime::Runtime::new()?;
match rt.block_on(run_metadata(
&config,
&workdir,
&mut analysis_output,
&diff_result.files,
)) {
Ok(()) => {}
Err(e) => {
warn!(
"LLM metadata pass failed, keeping existing group metadata: {}",
e
);
}
}
}

// Apply LLM annotation if requested
if args.annotate {
let rt = tokio::runtime::Runtime::new()?;
Expand Down Expand Up @@ -747,6 +782,54 @@ async fn run_refinement(
Ok(())
}

async fn run_metadata(
config: &DiffcoreConfig,
workdir: &std::path::Path,
analysis_output: &mut AnalysisOutput,
diffs: &[diffcore_core::git::FileDiff],
) -> Result<(), Box<dyn std::error::Error>> {
let metadata_llm_config = diffcore_core::config::LlmConfig {
provider: config
.llm
.metadata
.provider
.clone()
.or_else(|| config.llm.provider.clone()),
model: config
.llm
.metadata
.model
.clone()
.or_else(|| config.llm.model.clone()),
key_cmd: config
.llm
.metadata
.key_cmd
.clone()
.or_else(|| config.llm.key_cmd.clone()),
key: config
.llm
.metadata
.key
.clone()
.or_else(|| config.llm.key.clone()),
..Default::default()
};

let provider: std::sync::Arc<dyn llm::LlmProvider> =
llm::create_provider_for_workdir(&metadata_llm_config, Some(workdir))?.into();

llm::metadata::run_metadata_pass(
provider,
&mut analysis_output.groups,
diffs,
config.llm.metadata.batch_size,
)
.await?;

Ok(())
}

async fn run_annotation(
config: &DiffcoreConfig,
workdir: &std::path::Path,
Expand Down Expand Up @@ -1132,6 +1215,7 @@ fn run_export_groups(args: ExportGroupsArgs) -> Result<(), Box<dyn std::error::E
no_include_uncommitted: false,
output: None,
annotate: false,
describe: false,
refine: false,
refine_model: None,
no_cache: false,
Expand Down Expand Up @@ -1174,6 +1258,7 @@ fn run_import_groups(args: ImportGroupsArgs) -> Result<(), Box<dyn std::error::E
no_include_uncommitted: false,
output: None,
annotate: false,
describe: false,
refine: false,
refine_model: None,
no_cache: false,
Expand Down Expand Up @@ -1312,6 +1397,50 @@ mod tests {
let cli = Cli::parse_from(["diffcore", "analyze", "--base", "main", "--annotate"]);
if let Commands::Analyze(args) = cli.command {
assert!(args.annotate);
assert!(!args.describe);
} else {
panic!("expected Analyze command");
}
}

#[test]
fn test_parse_analyze_describe() {
let cli = Cli::parse_from(["diffcore", "analyze", "--base", "main", "--describe"]);
if let Commands::Analyze(args) = cli.command {
assert!(args.describe);
assert!(!args.refine, "--describe must not imply --refine");
assert!(!args.annotate, "--describe must not imply --annotate");
} else {
panic!("expected Analyze command");
}
}

#[test]
fn test_parse_analyze_describe_with_refine() {
let cli = Cli::parse_from([
"diffcore",
"analyze",
"--base",
"main",
"--refine",
"--describe",
]);
if let Commands::Analyze(args) = cli.command {
assert!(args.describe);
assert!(args.refine);
} else {
panic!("expected Analyze command");
}
}

#[test]
fn test_describe_defaults_off() {
let cli = Cli::parse_from(["diffcore", "analyze", "--base", "main"]);
if let Commands::Analyze(args) = cli.command {
assert!(
!args.describe,
"the metadata pass must never fire without --describe"
);
} else {
panic!("expected Analyze command");
}
Expand All @@ -1327,6 +1456,7 @@ mod tests {
"--head",
"feature",
"--annotate",
"--describe",
"--refine",
"--refine-model",
"gpt-4.1",
Expand All @@ -1339,6 +1469,7 @@ mod tests {
assert_eq!(args.base, Some("main".to_string()));
assert_eq!(args.head, Some("feature".to_string()));
assert!(args.annotate);
assert!(args.describe);
assert!(args.refine);
assert_eq!(args.refine_model, Some("gpt-4.1".to_string()));
assert_eq!(args.output, Some(PathBuf::from("out.json")));
Expand Down Expand Up @@ -1430,8 +1561,8 @@ mod tests {
provider: Some("openai".to_string()),
model: Some("gpt-4.1".to_string()),
key_cmd: Some("echo refinement-key".to_string()),
max_iterations: 2,
},
metadata: Default::default(),
},
..Default::default()
};
Expand Down Expand Up @@ -1485,8 +1616,8 @@ mod tests {
provider: None,
model: None,
key_cmd: None,
max_iterations: 1,
},
metadata: Default::default(),
},
..Default::default()
};
Expand Down
1 change: 1 addition & 0 deletions crates/diffcore-core/src/cluster/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ fn merge_group_indices(groups: &[FlowGroup], indices: &[usize], result: &mut Vec
edges: merged_edges,
risk_score: 0.0,
review_order: 0,
..Default::default()
});
}

Expand Down
5 changes: 4 additions & 1 deletion crates/diffcore-core/src/cluster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ mod embeddings_refine;
mod infra;
mod merge;
mod rescue;
mod stem;
pub(crate) mod stem;

#[cfg(test)]
#[allow(
Expand Down Expand Up @@ -183,6 +183,7 @@ fn build_no_entrypoint_component_groups(
edges,
risk_score: 0.0,
review_order: 0,
..Default::default()
});
} else {
isolated_files.extend(component);
Expand Down Expand Up @@ -232,6 +233,7 @@ fn build_no_entrypoint_directory_groups(source_files: &[String]) -> Vec<FlowGrou
edges: vec![],
risk_score: 0.0,
review_order: 0,
..Default::default()
}
})
.collect()
Expand Down Expand Up @@ -568,6 +570,7 @@ fn cluster_files_internal(
edges,
risk_score: 0.0,
review_order: 0,
..Default::default()
});
}

Expand Down
1 change: 1 addition & 0 deletions crates/diffcore-core/src/cluster/rescue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ mod tests {
edges: vec![],
risk_score: 0.0,
review_order: 0,
..Default::default()
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/diffcore-core/src/cluster/stem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ pub(super) fn test_impl_stem(path: &str) -> String {
}

/// Check if a file is a test file — by filename pattern OR directory.
pub(super) fn is_test_file_name(path: &str) -> bool {
pub(crate) fn is_test_file_name(path: &str) -> bool {
let lower = path.to_lowercase();
let filename = lower.rsplit('/').next().unwrap_or(&lower);

Expand Down
Loading