Skip to main content

code_moniker_workspace/changes/semantic/
review.rs

1use std::path::{Path, PathBuf};
2
3use code_moniker_core::core::code_graph::CodeGraph;
4use code_moniker_core::lang::Lang;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::environment;
8
9use super::super::diff::{
10	ChangeFile, ChangeScan, DiffHunk, DiffScope, FileDiff, FileDiffStatus, GitWorktree, HeadSide,
11	anchor_for, collect_changed_files, diff_path, display_rel_path, git_show, normalize_path,
12	resolve_base_rev, source_root_for_path,
13};
14use super::model::{HunkCoverage, RefChange, SymbolChange};
15use super::pairing::{FilePairing, FileSide, PairInputs, finish_files, pair_file};
16use super::refpairs::{CoverageInputs, RenameContext, hunk_coverage, pair_refs};
17use super::rollup::{FileDisposition, FileRollup, moved_file_rollup};
18
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct SemanticReview {
21	pub scope: String,
22	pub symbol_changes: Vec<SymbolChange>,
23	pub ref_changes: Vec<RefChange>,
24	pub files: Vec<FileFacts>,
25	pub diagnostics: Vec<String>,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct FileFacts {
30	pub rollup: FileRollup,
31	pub coverage: HunkCoverage,
32	pub analyzable: bool,
33}
34
35pub struct ReviewDiffs {
36	diffs: Vec<FileDiff>,
37	pub diagnostics: Vec<String>,
38	scope_label: String,
39	head_rev: Option<String>,
40	base_by_root: FxHashMap<PathBuf, String>,
41}
42
43impl ReviewDiffs {
44	pub fn current_paths(&self) -> Vec<PathBuf> {
45		self.diffs
46			.iter()
47			.filter(|diff| diff.status != FileDiffStatus::Deleted)
48			.map(|diff| normalize_path(&diff_path(diff)))
49			.collect()
50	}
51
52	pub fn current_rows(&self) -> Vec<(&Path, &Path)> {
53		self.diffs
54			.iter()
55			.filter(|diff| diff.status != FileDiffStatus::Deleted)
56			.map(|diff| (diff.repo_root.as_path(), diff.repo_rel.as_path()))
57			.collect()
58	}
59
60	pub fn head_rev(&self) -> Option<&str> {
61		self.head_rev.as_deref()
62	}
63
64	pub fn any_root_resolved(&self) -> bool {
65		!self.base_by_root.is_empty()
66	}
67
68	fn base_rev_for(&self, repo_root: &Path) -> &str {
69		self.base_by_root
70			.get(repo_root)
71			.map(String::as_str)
72			.unwrap_or("HEAD")
73	}
74}
75
76pub fn read_blob(repo_root: &Path, rev: &str, repo_rel: &Path) -> anyhow::Result<String> {
77	git_show(repo_root, rev, repo_rel)
78}
79
80pub fn collect_review_diffs(roots: &[(String, PathBuf)]) -> ReviewDiffs {
81	collect_review_diffs_scoped(roots, &DiffScope::worktree())
82}
83
84pub fn collect_review_diffs_scoped(roots: &[(String, PathBuf)], scope: &DiffScope) -> ReviewDiffs {
85	let mut review_diffs = ReviewDiffs {
86		diffs: Vec::new(),
87		diagnostics: Vec::new(),
88		scope_label: scope.label(),
89		head_rev: match &scope.head {
90			HeadSide::Rev(rev) => Some(rev.clone()),
91			HeadSide::Worktree => None,
92		},
93		base_by_root: FxHashMap::default(),
94	};
95	for (label, path) in roots {
96		collect_root_diffs(&mut review_diffs, label, path, scope);
97	}
98	review_diffs
99}
100
101fn collect_root_diffs(out: &mut ReviewDiffs, label: &str, path: &Path, scope: &DiffScope) {
102	let repo = match GitWorktree::discover(path) {
103		Ok(repo) => repo,
104		Err(message) => {
105			out.diagnostics.push(message);
106			return;
107		}
108	};
109	let base_rev = match resolve_base_rev(repo.root(), &scope.base) {
110		Ok(base_rev) => base_rev,
111		Err(message) => {
112			out.diagnostics.push(format!("{label}: {message}"));
113			return;
114		}
115	};
116	match collect_changed_files(repo.root(), path, &base_rev, &scope.head) {
117		Ok(mut root_diffs) => out.diffs.append(&mut root_diffs),
118		Err(error) => out
119			.diagnostics
120			.push(format!("{label}: cannot inspect git changes: {error}")),
121	}
122	out.base_by_root.insert(repo.root().to_path_buf(), base_rev);
123}
124
125pub fn build_semantic_review(scan: &ChangeScan<'_>) -> SemanticReview {
126	let roots: Vec<(String, PathBuf)> = scan
127		.roots
128		.iter()
129		.map(|root| (root.label.to_string(), root.path.to_path_buf()))
130		.collect();
131	build_semantic_review_from(scan, &collect_review_diffs(&roots))
132}
133
134pub fn build_semantic_review_from(scan: &ChangeScan<'_>, diffs: &ReviewDiffs) -> SemanticReview {
135	let mut review = SemanticReview {
136		scope: diffs.scope_label.clone(),
137		diagnostics: diffs.diagnostics.clone(),
138		..SemanticReview::default()
139	};
140	let pairs = review_pairs(scan, diffs, &mut review);
141	let pairings: Vec<FilePairing> = pairs
142		.iter()
143		.map(|pair| {
144			pair_file(PairInputs {
145				base: pair.base_side(),
146				current: pair.current_side(),
147				file_moved: pair.file_moved,
148			})
149		})
150		.collect();
151	review.symbol_changes = finish_files(pairings);
152	let ctx = rename_context(&review.symbol_changes, &pairs);
153	for pair in &pairs {
154		let refs = pair_refs(&pair.base_side(), &pair.current_side(), &ctx);
155		review
156			.files
157			.push(pair_facts(pair, &review.symbol_changes, &refs));
158		review.ref_changes.extend(refs);
159	}
160	review.files.sort_by_key(facts_order);
161	review
162}
163
164type LineSpans = Vec<(u32, u32)>;
165
166fn facts_order(facts: &FileFacts) -> (Option<PathBuf>, Option<PathBuf>) {
167	(
168		facts
169			.rollup
170			.new_path
171			.clone()
172			.or(facts.rollup.old_path.clone()),
173		facts.rollup.old_path.clone(),
174	)
175}
176
177enum CurrentRef<'scan> {
178	Scan(&'scan ChangeFile<'scan>),
179	Empty { source: String, graph: CodeGraph },
180}
181
182struct SidePair<'scan> {
183	old_rel: PathBuf,
184	new_rel: PathBuf,
185	lang: Lang,
186	base_source: String,
187	base_graph: CodeGraph,
188	current: CurrentRef<'scan>,
189	old_hunks: Vec<(u32, u32)>,
190	new_hunks: Vec<(u32, u32)>,
191	file_moved: bool,
192	disposition: FileDisposition,
193}
194
195impl SidePair<'_> {
196	fn base_side(&self) -> FileSide<'_> {
197		FileSide {
198			lang: self.lang,
199			graph: &self.base_graph,
200			source: &self.base_source,
201			file_path: &self.old_rel,
202		}
203	}
204
205	fn current_side(&self) -> FileSide<'_> {
206		match &self.current {
207			CurrentRef::Scan(file) => FileSide {
208				lang: self.lang,
209				graph: file.graph,
210				source: file.source,
211				file_path: &self.new_rel,
212			},
213			CurrentRef::Empty { source, graph } => FileSide {
214				lang: self.lang,
215				graph,
216				source,
217				file_path: &self.new_rel,
218			},
219		}
220	}
221}
222
223fn review_pairs<'scan>(
224	scan: &'scan ChangeScan<'scan>,
225	review_diffs: &ReviewDiffs,
226	review: &mut SemanticReview,
227) -> Vec<SidePair<'scan>> {
228	let diffs = &review_diffs.diffs;
229	let relevant = scan.relevant_diffs(diffs);
230	let matched: FxHashSet<PathBuf> = relevant.by_path.keys().cloned().collect();
231	let deleted: FxHashSet<PathBuf> = relevant
232		.deleted
233		.iter()
234		.map(|diff| normalize_path(&diff_path(diff)))
235		.collect();
236	let rename_origins: FxHashSet<PathBuf> = diffs
237		.iter()
238		.filter_map(|diff| diff.origin.as_ref())
239		.map(|origin| origin.repo_rel.clone())
240		.collect();
241	let mut pairs = Vec::new();
242	for diff in diffs {
243		if diff.status == FileDiffStatus::Deleted && rename_origins.contains(&diff.repo_rel) {
244			continue;
245		}
246		let path = normalize_path(&diff_path(diff));
247		let base_rev = review_diffs.base_rev_for(&diff.repo_root);
248		let outcome = if matched.contains(&path) {
249			scan_file_for(scan, &path)
250				.ok_or_else(|| format!("changed file left the catalog: {}", path.display()))
251				.and_then(|file| matched_pair(scan, diff, file, base_rev))
252		} else if deleted.contains(&path) {
253			deleted_pair(scan, diff, base_rev)
254		} else {
255			Err(String::new())
256		};
257		match outcome {
258			Ok(pair) => pairs.push(pair),
259			Err(message) => {
260				if !message.is_empty() {
261					review.diagnostics.push(message);
262				}
263				review.files.push(opaque_facts(scan, diff));
264			}
265		}
266	}
267	pairs
268}
269
270fn scan_file_for<'scan>(
271	scan: &'scan ChangeScan<'scan>,
272	path: &Path,
273) -> Option<&'scan ChangeFile<'scan>> {
274	scan.files
275		.iter()
276		.find(|file| normalize_path(file.path) == path)
277}
278
279fn matched_pair<'scan>(
280	scan: &ChangeScan<'_>,
281	diff: &FileDiff,
282	file: &'scan ChangeFile<'scan>,
283	base_rev: &str,
284) -> Result<SidePair<'scan>, String> {
285	let (old_hunks, new_hunks) = hunk_spans(&diff.hunks);
286	match diff.status {
287		FileDiffStatus::Added => Ok(SidePair {
288			old_rel: file.rel_path.to_path_buf(),
289			new_rel: file.rel_path.to_path_buf(),
290			lang: file.lang,
291			base_source: String::new(),
292			base_graph: extract_at(scan, file, file.anchor, ""),
293			current: CurrentRef::Scan(file),
294			old_hunks,
295			new_hunks,
296			file_moved: false,
297			disposition: FileDisposition::Added,
298		}),
299		FileDiffStatus::Renamed => renamed_pair(scan, diff, file, (old_hunks, new_hunks), base_rev),
300		_ => {
301			let base_source =
302				git_show(&diff.repo_root, base_rev, &diff.repo_rel).map_err(|error| {
303					format!(
304						"{}: cannot read base blob: {error}",
305						file.rel_path.display()
306					)
307				})?;
308			let base_graph = extract_at(scan, file, file.anchor, &base_source);
309			Ok(SidePair {
310				old_rel: file.rel_path.to_path_buf(),
311				new_rel: file.rel_path.to_path_buf(),
312				lang: file.lang,
313				base_source,
314				base_graph,
315				current: CurrentRef::Scan(file),
316				old_hunks,
317				new_hunks,
318				file_moved: false,
319				disposition: FileDisposition::Modified,
320			})
321		}
322	}
323}
324
325fn renamed_pair<'scan>(
326	scan: &ChangeScan<'_>,
327	diff: &FileDiff,
328	file: &'scan ChangeFile<'scan>,
329	hunks: (LineSpans, LineSpans),
330	base_rev: &str,
331) -> Result<SidePair<'scan>, String> {
332	let origin = diff.origin.as_ref().expect("renamed rows carry an origin");
333	let old_abs = diff.repo_root.join(&origin.repo_rel);
334	let Some((source_root, root, old_rel)) = source_root_for_path(scan, &old_abs) else {
335		return Err(format!(
336			"rename origin {} is outside the scanned source roots",
337			origin.repo_rel.display()
338		));
339	};
340	let old_anchor = anchor_for(scan, root, &old_rel);
341	let old_display = display_rel_path(scan, source_root, root, &old_rel);
342	let base_source = if origin.score == 100 {
343		file.source.to_string()
344	} else {
345		git_show(&diff.repo_root, base_rev, &origin.repo_rel)
346			.map_err(|error| format!("{}: cannot read base blob: {error}", old_display.display()))?
347	};
348	let base_graph =
349		environment::extract_source_with(file.lang, &base_source, &old_anchor, root.ctx);
350	Ok(SidePair {
351		old_rel: old_display,
352		new_rel: file.rel_path.to_path_buf(),
353		lang: file.lang,
354		base_source,
355		base_graph,
356		current: CurrentRef::Scan(file),
357		old_hunks: hunks.0,
358		new_hunks: hunks.1,
359		file_moved: true,
360		disposition: FileDisposition::Moved { pure: true },
361	})
362}
363
364fn deleted_pair<'scan>(
365	scan: &ChangeScan<'_>,
366	diff: &FileDiff,
367	base_rev: &str,
368) -> Result<SidePair<'scan>, String> {
369	let path = diff_path(diff);
370	let lang = environment::language_for_path(&path).map_err(|error| error.to_string())?;
371	let Some((source_root, root, old_rel)) = source_root_for_path(scan, &path) else {
372		return Err(format!(
373			"deleted file {} is outside the scanned source roots",
374			path.display()
375		));
376	};
377	let anchor = anchor_for(scan, root, &old_rel);
378	let old_display = display_rel_path(scan, source_root, root, &old_rel);
379	let base_source = git_show(&diff.repo_root, base_rev, &diff.repo_rel)
380		.map_err(|error| format!("{}: cannot read base blob: {error}", old_display.display()))?;
381	let base_graph = environment::extract_source_with(lang, &base_source, &anchor, root.ctx);
382	let empty_graph = environment::extract_source_with(lang, "", &anchor, root.ctx);
383	let (old_hunks, new_hunks) = hunk_spans(&diff.hunks);
384	Ok(SidePair {
385		new_rel: old_display.clone(),
386		old_rel: old_display,
387		lang,
388		base_source,
389		base_graph,
390		current: CurrentRef::Empty {
391			source: String::new(),
392			graph: empty_graph,
393		},
394		old_hunks,
395		new_hunks,
396		file_moved: false,
397		disposition: FileDisposition::Removed,
398	})
399}
400
401fn extract_at(
402	scan: &ChangeScan<'_>,
403	file: &ChangeFile<'_>,
404	anchor: &Path,
405	source: &str,
406) -> CodeGraph {
407	let root = &scan.roots[file.source_root];
408	environment::extract_source_with(file.lang, source, anchor, root.ctx)
409}
410
411fn hunk_spans(hunks: &[DiffHunk]) -> (LineSpans, LineSpans) {
412	let old = hunks
413		.iter()
414		.filter_map(|hunk| hunk.old)
415		.map(|span| (span.start, span.end))
416		.collect();
417	let new = hunks
418		.iter()
419		.filter_map(|hunk| hunk.new)
420		.map(|span| (span.start, span.end))
421		.collect();
422	(old, new)
423}
424
425fn opaque_facts(scan: &ChangeScan<'_>, diff: &FileDiff) -> FileFacts {
426	let path = diff_path(diff);
427	let rel = source_root_for_path(scan, &path)
428		.map(|(source_root, root, rel)| display_rel_path(scan, source_root, root, &rel))
429		.unwrap_or_else(|| diff.repo_rel.clone());
430	let (old_path, new_path, disposition) = match diff.status {
431		FileDiffStatus::Added => (None, Some(rel), FileDisposition::Added),
432		FileDiffStatus::Deleted => (Some(rel), None, FileDisposition::Removed),
433		_ => (Some(rel.clone()), Some(rel), FileDisposition::Modified),
434	};
435	FileFacts {
436		rollup: FileRollup {
437			old_path,
438			new_path,
439			disposition,
440			symbol_changes: 0,
441			moved_symbols: 0,
442		},
443		coverage: HunkCoverage::default(),
444		analyzable: false,
445	}
446}
447
448fn rename_context(changes: &[SymbolChange], pairs: &[SidePair<'_>]) -> RenameContext {
449	let mut ctx = RenameContext::from_changes(changes);
450	for pair in pairs.iter().filter(|pair| pair.file_moved) {
451		ctx.push_pair(
452			pair.base_graph.root().clone(),
453			pair.current_side().graph.root().clone(),
454		);
455	}
456	ctx
457}
458
459fn pair_facts(pair: &SidePair<'_>, changes: &[SymbolChange], refs: &[RefChange]) -> FileFacts {
460	let file_changes: Vec<SymbolChange> = changes
461		.iter()
462		.filter(|change| {
463			change
464				.old
465				.as_ref()
466				.is_some_and(|side| side.file_path == pair.old_rel)
467				|| change
468					.new
469					.as_ref()
470					.is_some_and(|side| side.file_path == pair.new_rel)
471		})
472		.cloned()
473		.collect();
474	let coverage = pair_coverage(pair, &file_changes, refs);
475	let mut rollup = match pair.disposition {
476		FileDisposition::Moved { .. } => {
477			moved_file_rollup(pair.old_rel.clone(), pair.new_rel.clone(), &file_changes)
478		}
479		disposition => plain_rollup(pair, disposition, &file_changes),
480	};
481	if rollup.disposition == (FileDisposition::Moved { pure: true }) && !coverage.explained() {
482		rollup.disposition = FileDisposition::Moved { pure: false };
483	}
484	FileFacts {
485		rollup,
486		coverage,
487		analyzable: true,
488	}
489}
490
491fn plain_rollup(
492	pair: &SidePair<'_>,
493	disposition: FileDisposition,
494	file_changes: &[SymbolChange],
495) -> FileRollup {
496	let keep_old = !matches!(disposition, FileDisposition::Added);
497	let keep_new = !matches!(disposition, FileDisposition::Removed);
498	FileRollup {
499		old_path: keep_old.then(|| pair.old_rel.clone()),
500		new_path: keep_new.then(|| pair.new_rel.clone()),
501		disposition,
502		symbol_changes: file_changes.len(),
503		moved_symbols: 0,
504	}
505}
506
507fn pair_coverage(
508	pair: &SidePair<'_>,
509	file_changes: &[SymbolChange],
510	refs: &[RefChange],
511) -> HunkCoverage {
512	let mut old_explained: Vec<(u32, u32)> = Vec::new();
513	let mut new_explained: Vec<(u32, u32)> = Vec::new();
514	for change in file_changes {
515		if let Some(range) = change
516			.old
517			.as_ref()
518			.filter(|side| side.file_path == pair.old_rel)
519			.and_then(|side| side.line_range)
520		{
521			old_explained.push(range);
522		}
523		if let Some(range) = change
524			.new
525			.as_ref()
526			.filter(|side| side.file_path == pair.new_rel)
527			.and_then(|side| side.line_range)
528		{
529			new_explained.push(range);
530		}
531	}
532	for reference in refs {
533		old_explained.extend(reference.old_line_range);
534		new_explained.extend(reference.new_line_range);
535	}
536	hunk_coverage(CoverageInputs {
537		old_hunks: &pair.old_hunks,
538		new_hunks: &pair.new_hunks,
539		old_explained: &old_explained,
540		new_explained: &new_explained,
541	})
542}
543
544#[cfg(test)]
545mod tests {
546	use super::super::super::diff::ChangeRoot;
547	use super::super::model::{RefChangeKind, SemanticKind};
548	use super::*;
549	use crate::environment::ExtractContext;
550	use std::process::Command;
551
552	fn write(root: &Path, rel: &str, body: &str) {
553		let path = root.join(rel);
554		if let Some(parent) = path.parent() {
555			std::fs::create_dir_all(parent).unwrap();
556		}
557		std::fs::write(path, body).unwrap();
558	}
559
560	fn git(root: &Path, args: &[&str]) {
561		let output = Command::new("git")
562			.arg("-C")
563			.arg(root)
564			.args(args)
565			.output()
566			.unwrap_or_else(|e| panic!("cannot run git {args:?}: {e}"));
567		assert!(
568			output.status.success(),
569			"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
570			String::from_utf8_lossy(&output.stdout),
571			String::from_utf8_lossy(&output.stderr)
572		);
573	}
574
575	struct ScanFixture {
576		root: PathBuf,
577		files: Vec<(PathBuf, String, String)>,
578		graphs: Vec<CodeGraph>,
579	}
580
581	impl ScanFixture {
582		fn new(root: &Path, rels: &[&str]) -> Self {
583			let files: Vec<(PathBuf, String, String)> = rels
584				.iter()
585				.map(|rel| {
586					let path = root.join(rel);
587					let source = std::fs::read_to_string(&path).unwrap();
588					(path, rel.to_string(), source)
589				})
590				.collect();
591			let graphs = files
592				.iter()
593				.map(|(_, rel, source)| {
594					environment::extract_source(Lang::Rs, source, Path::new(rel))
595				})
596				.collect();
597			Self {
598				root: root.to_path_buf(),
599				files,
600				graphs,
601			}
602		}
603
604		fn scan<'a>(&'a self, ctx: &'a ExtractContext) -> ChangeScan<'a> {
605			ChangeScan {
606				roots: vec![ChangeRoot {
607					label: "repo",
608					path: &self.root,
609					ctx,
610				}],
611				files: self
612					.files
613					.iter()
614					.zip(&self.graphs)
615					.enumerate()
616					.map(|(file_idx, ((path, rel, source), graph))| ChangeFile {
617						file_idx,
618						source_root: 0,
619						path,
620						rel_path: Path::new(rel),
621						anchor: Path::new(rel),
622						lang: Lang::Rs,
623						graph,
624						source,
625					})
626					.collect(),
627			}
628		}
629	}
630
631	#[test]
632	fn review_reports_move_edit_retarget_and_opaque_facts_together() {
633		let tmp = tempfile::tempdir().unwrap();
634		git(tmp.path(), &["init"]);
635		git(tmp.path(), &["config", "user.email", "cm@example.test"]);
636		git(tmp.path(), &["config", "user.name", "Code Moniker"]);
637		write(tmp.path(), "Cargo.toml", "[package]\nname = \"demo\"\n");
638		write(tmp.path(), "src/lib.rs", "mod util;\nmod consumer;\n");
639		write(
640			tmp.path(),
641			"src/util.rs",
642			"pub fn assist() { work(); }\npub fn sidekick() { rest(); }\n",
643		);
644		write(
645			tmp.path(),
646			"src/consumer.rs",
647			"use crate::util::assist;\n\npub fn caller() { assist(); }\npub fn edited() -> u32 { 1 }\n",
648		);
649		git(tmp.path(), &["add", "."]);
650		git(tmp.path(), &["commit", "-m", "initial"]);
651		git(tmp.path(), &["mv", "src/util.rs", "src/support.rs"]);
652		write(
653			tmp.path(),
654			"Cargo.toml",
655			"[package]\nname = \"demo\"\nedition = \"2024\"\n",
656		);
657		write(tmp.path(), "src/lib.rs", "mod support;\nmod consumer;\n");
658		write(
659			tmp.path(),
660			"src/consumer.rs",
661			"use crate::support::assist;\n\npub fn caller() { assist(); }\npub fn edited() -> u32 { 2 }\n",
662		);
663		let fixture = ScanFixture::new(
664			tmp.path(),
665			&["src/lib.rs", "src/consumer.rs", "src/support.rs"],
666		);
667		let ctx = ExtractContext::default();
668
669		let review = build_semantic_review(&fixture.scan(&ctx));
670
671		assert!(review.diagnostics.is_empty(), "{:?}", review.diagnostics);
672		let moved = review
673			.files
674			.iter()
675			.find(|facts| facts.rollup.old_path.as_deref() == Some(Path::new("src/util.rs")))
676			.expect("moved file facts");
677		assert_eq!(
678			moved.rollup.new_path.as_deref(),
679			Some(Path::new("src/support.rs"))
680		);
681		assert_eq!(
682			moved.rollup.disposition,
683			FileDisposition::Moved { pure: true },
684			"{moved:?}"
685		);
686		let opaque = review
687			.files
688			.iter()
689			.find(|facts| facts.rollup.new_path.as_deref() == Some(Path::new("Cargo.toml")))
690			.expect("manifest facts");
691		assert!(!opaque.analyzable);
692		let kinds: Vec<SemanticKind> = review
693			.symbol_changes
694			.iter()
695			.map(|change| change.kind)
696			.collect();
697		assert!(kinds.contains(&SemanticKind::BodyModified), "{kinds:?}");
698		assert!(kinds.contains(&SemanticKind::Moved), "{kinds:?}");
699		assert!(
700			!kinds.contains(&SemanticKind::Added) && !kinds.contains(&SemanticKind::Removed),
701			"everything must pair: {:?}",
702			review.symbol_changes
703		);
704		assert!(
705			review
706				.ref_changes
707				.iter()
708				.any(|change| change.kind == RefChangeKind::ImportRetargeted),
709			"{:?}",
710			review.ref_changes
711		);
712		let consumer = review
713			.files
714			.iter()
715			.find(|facts| facts.rollup.new_path.as_deref() == Some(Path::new("src/consumer.rs")))
716			.expect("consumer facts");
717		assert!(
718			consumer.coverage.explained(),
719			"import retarget and body edit must explain every hunk: {consumer:?}"
720		);
721	}
722
723	#[test]
724	fn scoped_review_classifies_a_committed_rename_between_revisions() {
725		let tmp = tempfile::tempdir().unwrap();
726		git(tmp.path(), &["init"]);
727		git(tmp.path(), &["config", "user.email", "cm@example.test"]);
728		git(tmp.path(), &["config", "user.name", "Code Moniker"]);
729		write(
730			tmp.path(),
731			"src/util.rs",
732			"pub fn assist() { work(); }\npub fn sidekick() { rest(); }\n",
733		);
734		git(tmp.path(), &["add", "."]);
735		git(tmp.path(), &["commit", "-m", "initial"]);
736		git(tmp.path(), &["mv", "src/util.rs", "src/support.rs"]);
737		git(tmp.path(), &["commit", "-am", "move"]);
738		let fixture = ScanFixture::new(tmp.path(), &["src/support.rs"]);
739		let ctx = ExtractContext::default();
740		let roots = vec![("repo".to_string(), tmp.path().to_path_buf())];
741
742		for range in ["HEAD~1..HEAD", "HEAD~1...HEAD"] {
743			let scope = DiffScope::parse_range(range).unwrap();
744			let diffs = collect_review_diffs_scoped(&roots, &scope);
745			assert!(diffs.any_root_resolved(), "{:?}", diffs.diagnostics);
746			let review = build_semantic_review_from(&fixture.scan(&ctx), &diffs);
747
748			assert!(review.scope.contains(".."), "{}", review.scope);
749			let moved = review
750				.files
751				.iter()
752				.find(|facts| facts.rollup.old_path.as_deref() == Some(Path::new("src/util.rs")))
753				.unwrap_or_else(|| panic!("moved facts for {range}: {:?}", review.files));
754			assert_eq!(
755				moved.rollup.disposition,
756				FileDisposition::Moved { pure: true },
757				"{range}: {moved:?}"
758			);
759		}
760	}
761
762	#[test]
763	fn scoped_review_reports_unresolvable_revisions() {
764		let tmp = tempfile::tempdir().unwrap();
765		git(tmp.path(), &["init"]);
766		git(tmp.path(), &["config", "user.email", "cm@example.test"]);
767		git(tmp.path(), &["config", "user.name", "Code Moniker"]);
768		write(tmp.path(), "src/lib.rs", "fn lone() {}\n");
769		git(tmp.path(), &["add", "."]);
770		git(tmp.path(), &["commit", "-m", "initial"]);
771		let roots = vec![("repo".to_string(), tmp.path().to_path_buf())];
772
773		let scope = DiffScope::parse_range("no-such-rev..HEAD").unwrap();
774		let diffs = collect_review_diffs_scoped(&roots, &scope);
775
776		assert!(!diffs.any_root_resolved());
777		assert!(
778			diffs
779				.diagnostics
780				.iter()
781				.any(|message| message.contains("no-such-rev")),
782			"{:?}",
783			diffs.diagnostics
784		);
785	}
786
787	#[test]
788	fn review_flags_unattributed_edits_as_residual() {
789		let tmp = tempfile::tempdir().unwrap();
790		git(tmp.path(), &["init"]);
791		git(tmp.path(), &["config", "user.email", "cm@example.test"]);
792		git(tmp.path(), &["config", "user.name", "Code Moniker"]);
793		write(
794			tmp.path(),
795			"src/lib.rs",
796			"fn steady() { work(); }\n// note\n",
797		);
798		git(tmp.path(), &["add", "."]);
799		git(tmp.path(), &["commit", "-m", "initial"]);
800		write(
801			tmp.path(),
802			"src/lib.rs",
803			"fn steady() { work(); }\n// reworded note\n",
804		);
805		let fixture = ScanFixture::new(tmp.path(), &["src/lib.rs"]);
806		let ctx = ExtractContext::default();
807
808		let review = build_semantic_review(&fixture.scan(&ctx));
809
810		let facts = review.files.first().expect("file facts");
811		assert!(
812			!facts.coverage.explained(),
813			"a comment-only edit has no symbolic fact and must stay residual: {facts:?}"
814		);
815		assert!(
816			review.symbol_changes.is_empty(),
817			"{:?}",
818			review.symbol_changes
819		);
820	}
821}