weave_cli/worktree.rs
1//! Verifying the WORKING TREE — the check an agent actually wants.
2//!
3//! `weave check` used to compare `HEAD` against `MERGE_HEAD`: two *commits*,
4//! not the working-tree resolution someone had just written. That misses the
5//! dominant real defect in these merges, which is **not** a mis-resolved
6//! marker — it is silent breakage in the *unconflicted* region that no marker
7//! points at, findable only by reading the whole file by hand. That reading
8//! is the cost this module exists to remove.
9//!
10//! So the subject is the file on disk, and the questions are the four an agent
11//! asks after editing:
12//!
13//! 1. **markers** — did I finish? (including weave's own teach line)
14//! 2. **loss** — did I drop a line BOTH sides kept? Unanimity is the one thing
15//! a resolution may never overrule: neither developer asked for it to go.
16//! 3. **duplicates** — did the merge state something more times than either
17//! side did? Same ruler as [`weave_core::frame`], applied to the whole file
18//! instead of the frame.
19//! 4. **dangling** — is anything still called that nothing defines any more?
20//! Binding evidence, repo-wide, the same functions the per-file pass uses.
21//!
22//! Every verdict is stated as a **sentence**, never as an empty array. A
23//! reader who gets back a bare `[]` reasonably takes it for approval; a
24//! channel whose "nothing found" and "nothing looked at" render identically
25//! has a failure mode where silence is indistinguishable from a clean bill of
26//! health.
27//!
28//! ## Why the bounds below are the ones they are
29//!
30//! A verifier that replaces a manual pass is only worth its calls if its
31//! findings are evidence. Every rule here is therefore stated as a *necessary*
32//! condition on a correct resolution — one the developer's own committed
33//! resolution satisfies — and the three ways that can be got wrong are all
34//! ways this file once got them wrong:
35//!
36//! * **The unanimity floor is base-relative and additive.** `min(ours, theirs)`
37//! is what a merge must keep only if the two sides always delete the *same*
38//! copies of a repeated line, which on real code they never do. The bound is
39//! `min(n, ours) + min(n, theirs) − n` over base's `n` copies, floored at
40//! zero — the predicate [`weave_core::container`] enforces inside the merge.
41//! Two owners of one rule is one owner too many, so this states it the same
42//! way rather than a second way.
43//! * **The duplication ceiling is additive too.** Two sides that each add a
44//! copy of the same line license a resolution that has one *more* copy than
45//! either side does; `max(base, ours, theirs)` calls every such union a
46//! duplicate. The ceiling is the one [`weave_core::frame::frame_duplicates`]
47//! already uses.
48//! * **Multiplicity is only evidence for a line that has an identity.**
49//! `frame` says this for structural punctuation. It is equally true of a
50//! continuation fragment — `anyInt(),`, `await fire_event(` — which is not a
51//! statement but a slice of one, and repeats wherever the same call shape is
52//! written. A fragment counts only when it is part of a *run* of over-budget
53//! lines, which is what a doubled edit actually looks like.
54//!
55//! and one that is about names rather than lines:
56//!
57//! * **A declaration and its impl blocks are ONE nameable thing.** Rust's
58//! `struct X` plus `impl X` is not `X` defined twice; neither is a namespace
59//! reopened, an interface merged, two `test('…')` calls sharing a title, or
60//! two overloads sharing a name. Identity is (kind, name, signature) with the
61//! kinds that *attach* to a declaration excluded — not the bare name.
62
63use std::collections::{BTreeMap, BTreeSet, HashMap};
64
65use sem_core::model::change::ChangeType;
66use sem_core::model::entity::SemanticEntity;
67use sem_core::model::identity::match_entities;
68
69use crate::parsers::{entities_of, is_supported};
70use crate::repo_scope::Tree;
71use weave_core::binding::{has_call_reference, has_definition};
72
73/// A line is only evidence if it says something. Bare punctuation (`}`, `);`,
74/// `else:`) repeats legitimately all over a real file, so counting it would
75/// bury every real finding under closing braces.
76fn significant(line: &str) -> bool {
77 let t = line.trim();
78 t.len() >= 8 && t.chars().any(|c| c.is_alphanumeric())
79}
80
81/// Is this line a *slice* of a statement rather than a statement?
82///
83/// A wrapped call argument (`any(IMediaType.class), anyInt(),`), an opening
84/// header (`await event.fire_event_async(`, `except zmq.Error:`, `impl Foo {`)
85/// and a leading continuation (`.map(|x| x + 1)`) all repeat legitimately
86/// wherever the same shape is written again, so their multiplicity says
87/// nothing about the merge. This is [`weave_core::frame`]'s "structural
88/// punctuation carries no identity", one level up: *fragments* carry none
89/// either.
90fn is_fragment(line: &str) -> bool {
91 let t = line.trim();
92 let opens_or_continues = |s: &str| {
93 s.ends_with([
94 ',', '(', '[', '{', ':', '+', '-', '*', '/', '&', '|', '=', '<', '>', '?',
95 ]) || s.ends_with("=>")
96 || s.ends_with("->")
97 };
98 let starts_as_continuation = t.starts_with(['.', ',', ')', ']', '}', '?', ':', '+'])
99 || t.starts_with("&&")
100 || t.starts_with("||");
101 opens_or_continues(t) || starts_as_continuation
102}
103
104/// The lines the multiset rules are allowed to reason about.
105fn carries_identity(line: &str) -> bool {
106 significant(line) && !is_fragment(line)
107}
108
109fn counts(text: &str) -> HashMap<&str, usize> {
110 let mut m: HashMap<&str, usize> = HashMap::new();
111 for l in text.lines().map(str::trim).filter(|l| significant(l)) {
112 *m.entry(l).or_insert(0) += 1;
113 }
114 m
115}
116
117/// One finding about one file, already in the words the reader gets.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Finding {
120 /// `MARKERS` | `LOSS` | `DUP` | `DANGLING`.
121 pub class: &'static str,
122 pub detail: String,
123 /// A repair that follows mechanically from the finding, when one does.
124 /// `None` is honest silence, not an empty string.
125 pub suggestion: Option<String>,
126}
127
128/// One file's verdict. An empty `findings` is the OK verdict and prints as a
129/// sentence.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct Verdict {
132 pub file: String,
133 pub findings: Vec<Finding>,
134}
135
136impl Verdict {
137 pub fn ok(&self) -> bool {
138 self.findings.is_empty()
139 }
140
141 /// The one line this file gets.
142 pub fn line(&self) -> String {
143 if self.ok() {
144 return format!(
145 "OK: {} — markers cleared, no unanimous-line loss, no duplicated \
146 definitions or lines, no dangling references",
147 self.file
148 );
149 }
150 let details: Vec<&str> = self.findings.iter().map(|f| f.detail.as_str()).collect();
151 format!("FOUND: {} — {}", self.file, details.join("; "))
152 }
153}
154
155/// Everything one run of the working-tree check looked at and concluded.
156///
157/// The *scope* travels with the verdicts on purpose: "no findings" and "no
158/// files" are different answers and a reader must never have to infer which
159/// one they got.
160#[derive(Debug, Clone)]
161pub struct Report {
162 /// What was compared, in words — `HEAD × MERGE_HEAD`, or why nothing was.
163 pub scope: String,
164 pub verdicts: Vec<Verdict>,
165}
166
167impl Report {
168 /// (files verified clean, files with findings, findings). One pass, one
169 /// answer: the summary sentence and the exit code are two readings of the
170 /// same tally, and computing them separately is how they drift.
171 pub fn tally(&self) -> (usize, usize, usize) {
172 let mut clean = 0;
173 let mut dirty = 0;
174 let mut findings = 0;
175 for v in &self.verdicts {
176 if v.ok() {
177 clean += 1;
178 } else {
179 dirty += 1;
180 }
181 findings += v.findings.len();
182 }
183 (clean, dirty, findings)
184 }
185
186 /// The whole report as the text a reader gets. Never empty, ever.
187 pub fn render(&self) -> String {
188 let mut out = format!("weave check: {}\n", self.scope);
189 if self.verdicts.is_empty() {
190 out.push_str(
191 "weave check: NOTHING WAS VERIFIED. This is not a clean bill of health — \
192 weave had no file to look at.\n",
193 );
194 return out;
195 }
196 for v in &self.verdicts {
197 out.push_str(&v.line());
198 out.push('\n');
199 for f in &v.findings {
200 if let Some(s) = &f.suggestion {
201 out.push_str(&format!(" suggestion ({}): {}\n", f.class, s));
202 }
203 }
204 }
205 let (good, bad, findings) = self.tally();
206 if bad == 0 {
207 out.push_str(&format!(
208 "weave check: {good} file(s) verified against the three merge stages — \
209 every line both sides kept is present, nothing is stated more often than \
210 either side stated it, every reference still resolves. Re-reading these \
211 files end to end will not find anything this did not.\n"
212 ));
213 } else {
214 out.push_str(&format!(
215 "weave check: {good} file(s) clean, {bad} file(s) with findings \
216 ({findings} total). Fix the files above; the clean ones need no further \
217 reading.\n"
218 ));
219 }
220 out
221 }
222}
223
224/// The four checks, over one file.
225///
226/// `work` is the bytes on disk. `base` / `ours` / `theirs` are the three merge
227/// stages — the only reference frame in which "loss" and "duplicate" mean
228/// anything, because both are statements about what the two developers agreed.
229fn verify_file(
230 file: &str,
231 base: Option<&str>,
232 ours: Option<&str>,
233 theirs: Option<&str>,
234 work: &str,
235) -> Vec<Finding> {
236 let mut out = Vec::new();
237
238 // ---- 1. Markers -------------------------------------------------------
239 let marker_lines = weave_core::frame::marker_line_count(work);
240 if marker_lines > 0 {
241 out.push(Finding {
242 class: "MARKERS",
243 detail: format!("{marker_lines} conflict marker line(s) still present"),
244 suggestion: Some(
245 "the file is not resolved — every opening, separator and closing marker \
246 line must go, along with the refused_by: comment inside each box"
247 .to_string(),
248 ),
249 });
250 }
251 if work.lines().any(weave_core::conflict::is_teach_line) {
252 out.push(Finding {
253 class: "MARKERS",
254 detail: "weave's teach line is still in the file".to_string(),
255 suggestion: Some(
256 "delete the trailing `weave: run 'weave explain …'` comment; it is marker \
257 furniture, not source"
258 .to_string(),
259 ),
260 });
261 }
262
263 // ---- 2. Unanimous loss ------------------------------------------------
264 //
265 // A line both sides kept is a line neither developer asked to remove. That
266 // is the one class where "the resolver chose differently" is not a defence.
267 //
268 // The bound is base-relative and additive, because deletions from the two
269 // sides are: of base's `n` copies, ours kept `min(n, ours)` and theirs kept
270 // `min(n, theirs)`, and nothing says those are the same copies, so the
271 // survivors are the sum minus `n`. `min(ours, theirs)` — what this used to
272 // ask — is a condition the *developer's own resolution* fails wherever the
273 // two sides deleted different copies of a repeated line.
274 if let (Some(b), Some(o), Some(t)) = (base, ours, theirs) {
275 let (cb, co, ct, cw) = (counts(b), counts(o), counts(t), counts(work));
276 let mut lost: Vec<(&str, usize)> = cb
277 .iter()
278 .filter_map(|(line, n)| {
279 let kept_o = co.get(line).copied().unwrap_or(0).min(*n);
280 let kept_t = ct.get(line).copied().unwrap_or(0).min(*n);
281 // Both sides may between them have deleted every copy; that is
282 // a fact about the input, not an underflow to repair.
283 // `saturating_sub` would read the same and clippy asks for it,
284 // but the analyzer that looks for silent-repair patterns would
285 // flag it as one — and this is not a repair, it is the
286 // arithmetic. Written out, both checkers are told the truth.
287 #[allow(clippy::implicit_saturating_sub)]
288 let required = if kept_o + kept_t > *n {
289 kept_o + kept_t - *n
290 } else {
291 0
292 };
293 let found = cw.get(line).copied().unwrap_or(0);
294 // `then`, not `then_some`: the argument of `then_some` is
295 // evaluated whether or not the condition holds, and
296 // `required - found` underflows on every line that is NOT lost
297 // — which is almost all of them.
298 (found < required).then(|| (*line, required - found))
299 })
300 .collect();
301 lost.sort();
302 if !lost.is_empty() {
303 let total: usize = lost.iter().map(|(_, n)| n).sum();
304 out.push(Finding {
305 class: "LOSS",
306 detail: format!(
307 "{total} line(s) BOTH sides kept are missing, e.g. `{}`",
308 clip(lost[0].0)
309 ),
310 suggestion: Some(format!(
311 "restore the {} distinct line(s) neither side deleted; nothing in this \
312 merge licenses dropping them",
313 lost.len()
314 )),
315 });
316 }
317 }
318
319 // ---- 3. Duplication ---------------------------------------------------
320 //
321 // Same ruler as the frame check: a line may appear as often as the side
322 // that says it most says it, and no oftener. This is checked over the
323 // whole file and not only over the frame because git folds one side's
324 // rename into "clean" context and the declaration lands twice, with no
325 // marker anywhere near it.
326 {
327 let cw = counts(work);
328 let (cb, co, ct) = (
329 counts(base.unwrap_or("")),
330 counts(ours.unwrap_or("")),
331 counts(theirs.unwrap_or("")),
332 );
333 // The ceiling `frame_duplicates` already uses. Additions from the two
334 // sides are additive exactly as deletions are, so a resolution that
335 // keeps both sides' new copy of a line is inside its budget — and at
336 // least one copy is always allowed, because a line the resolver wrote
337 // itself is allowed to exist. Twice is the question.
338 let allowance = |line: &str| {
339 let (n, o, t) = (
340 cb.get(line).copied().unwrap_or(0),
341 co.get(line).copied().unwrap_or(0),
342 ct.get(line).copied().unwrap_or(0),
343 );
344 // Written out rather than saturating, for the same reason the
345 // unanimity floor above is: both sides may between them have kept
346 // fewer copies than base wrote, and that is the arithmetic, not an
347 // underflow to repair.
348 #[allow(clippy::implicit_saturating_sub)]
349 let additive = if o + t > n { o + t - n } else { 0 };
350 additive.max(o).max(t).max(1)
351 };
352 let over: BTreeSet<&str> = cw
353 .iter()
354 .filter(|(line, found)| **found > allowance(line))
355 .map(|(line, _)| *line)
356 .collect();
357 // A fragment is only evidence in company: a doubled edit lands as a
358 // RUN of over-budget lines, whereas a call argument that repeats on its
359 // own is how the same call shape gets written twice. Adjacency is asked
360 // of the working tree, because that is where the run would be.
361 let mut in_a_run: BTreeSet<&str> = BTreeSet::new();
362 {
363 let lines: Vec<&str> = work.lines().map(str::trim).collect();
364 for pair in lines.windows(2) {
365 if over.contains(pair[0]) && over.contains(pair[1]) {
366 in_a_run.insert(pair[0]);
367 in_a_run.insert(pair[1]);
368 }
369 }
370 }
371 let mut dup: Vec<(&str, usize, usize)> = over
372 .iter()
373 .filter(|line| carries_identity(line) || in_a_run.contains(*line))
374 .map(|line| (*line, cw[line], allowance(line)))
375 .collect();
376 dup.sort();
377 if !dup.is_empty() {
378 out.push(Finding {
379 class: "DUP",
380 detail: format!(
381 "{} line(s) appear more often than any version states them, e.g. `{}` \
382 ({}x, at most {}x in base/ours/theirs)",
383 dup.len(),
384 clip(dup[0].0),
385 dup[0].1,
386 dup[0].2
387 ),
388 suggestion: Some(
389 "delete the extra copies — a line stated twice usually means one side's \
390 edit landed both inside and outside a marker"
391 .to_string(),
392 ),
393 });
394 }
395 }
396
397 // Duplicate DEFINITIONS are worth their own finding: two `def f` in one
398 // file is a language-level bug, not a stylistic repeat, and the second one
399 // silently wins. Which is why the *identity* has to be the language's and
400 // not the name string's — see [`definition_key`].
401 let mut seen: BTreeMap<DefinitionKey, (String, usize)> = BTreeMap::new();
402 for e in entities_of(file, work) {
403 let Some(key) = definition_key(file, &e) else {
404 continue;
405 };
406 let slot = seen.entry(key).or_insert_with(|| (e.name.clone(), 0));
407 slot.1 += 1;
408 }
409 let dup_defs: Vec<&(String, usize)> = seen.values().filter(|(_, n)| *n > 1).collect();
410 if !dup_defs.is_empty() {
411 out.push(Finding {
412 class: "DUP",
413 detail: format!(
414 "{} name(s) are defined more than once: {}",
415 dup_defs.len(),
416 dup_defs
417 .iter()
418 .take(4)
419 .map(|(n, c)| format!("`{n}` ({c}x)"))
420 .collect::<Vec<_>>()
421 .join(", ")
422 ),
423 suggestion: Some(
424 "keep one definition of each name; the later one shadows the earlier and \
425 the earlier one's edits are silently dead"
426 .to_string(),
427 ),
428 });
429 }
430
431 out
432}
433
434/// What makes two top-level items the SAME declaration: the kind, the name with
435/// its generic arguments off, and — where the language overloads — the
436/// parameter list.
437type DefinitionKey = (String, String, Option<String>);
438
439/// Kinds that do not declare a name of their own.
440///
441/// Three groups, and each one is a false positive the v3 trial paid for:
442///
443/// * **attachments** — `impl`, `extension`, `instance`. `impl X` does not
444/// define `X`; it adds to the `X` a declaration elsewhere already made, and
445/// a type may have as many inherent impls as it likes. This is the rule
446/// whose absence made `struct UpdateDiff` + `impl UpdateDiff` read as
447/// "`UpdateDiff` defined 2x" on a resolution that was correct.
448/// * **reopenable declarations** — a namespace, a module or a TypeScript
449/// interface stated twice is declaration *merging*, which is the language
450/// working as designed, not a name silently shadowed.
451/// * **calls that are not declarations at all** — `test('…')` / `it('…')` /
452/// `describe('…')` are function calls whose first argument the extractor
453/// uses as a name. Two tests may share a title; nothing shadows anything.
454fn declares_a_name(entity_type: &str) -> bool {
455 !matches!(
456 entity_type,
457 "impl"
458 | "extension"
459 | "instance"
460 | "module"
461 | "internal_module"
462 | "namespace"
463 | "interface"
464 | "test"
465 | "test_suite"
466 | "export"
467 | "import"
468 | "package"
469 | "use"
470 )
471}
472
473/// Languages where two items may share a name and differ only in their
474/// parameters. In these, a repeated name is an overload until the signatures
475/// match; everywhere else a repeated name is a redefinition.
476fn overloads(path: &str) -> bool {
477 matches!(
478 path.rsplit('.').next().unwrap_or(""),
479 "java"
480 | "kt"
481 | "kts"
482 | "cs"
483 | "cpp"
484 | "cc"
485 | "cxx"
486 | "hpp"
487 | "hh"
488 | "h"
489 | "c"
490 | "ts"
491 | "tsx"
492 | "swift"
493 | "scala"
494 | "php"
495 )
496}
497
498/// `Bar<T>` and `Bar<U>` are one nameable thing; the generic arguments are the
499/// item's parameters, not part of its name.
500fn without_generics(name: &str) -> &str {
501 match name.find('<') {
502 Some(i) if i > 0 => name[..i].trim_end(),
503 _ => name,
504 }
505}
506
507/// The parameter list of an item's header, whitespace removed — `None` when the
508/// item has no parameter list to compare (a struct, an enum, a constant).
509fn parameter_list(content: &str) -> Option<String> {
510 let header: String = content.lines().take(3).collect::<Vec<_>>().join(" ");
511 let open = header.find('(')?;
512 let mut depth = 0usize;
513 for (i, c) in header[open..].char_indices() {
514 match c {
515 '(' => depth += 1,
516 ')' => {
517 depth -= 1;
518 if depth == 0 {
519 return Some(
520 header[open + 1..open + i]
521 .chars()
522 .filter(|c| !c.is_whitespace())
523 .collect(),
524 );
525 }
526 }
527 _ => {}
528 }
529 }
530 None
531}
532
533/// The identity a duplicate-definition finding is entitled to compare, or
534/// `None` for an item that declares nothing.
535fn definition_key(path: &str, e: &SemanticEntity) -> Option<DefinitionKey> {
536 if e.name.is_empty() || !declares_a_name(&e.entity_type) {
537 return None;
538 }
539 let signature = overloads(path)
540 .then(|| parameter_list(&e.content))
541 .flatten();
542 Some((
543 e.entity_type.clone(),
544 without_generics(&e.name).to_string(),
545 signature,
546 ))
547}
548
549fn clip(l: &str) -> String {
550 let t = l.trim();
551 if t.chars().count() > 72 {
552 t.chars().take(71).chain(['…']).collect()
553 } else {
554 t.to_string()
555 }
556}
557
558/// Verify a whole working tree against the three merge stages.
559///
560/// `work` holds the bytes on disk for every supported file — the dangling pass
561/// needs the *repo*, not the file, because a definition deleted in `a.py` with
562/// its caller in `b.py` is invisible to any per-file rule.
563pub fn check(
564 base: &Tree,
565 ours: &Tree,
566 theirs: &Tree,
567 work: &Tree,
568 subjects: &[String],
569) -> Vec<Verdict> {
570 // Names still defined anywhere on disk, and where each stage defined what.
571 let defined_now: BTreeSet<String> = work
572 .iter()
573 .filter(|(p, _)| is_supported(p))
574 .flat_map(|(p, c)| entities_of(p, c).into_iter().map(|e| e.name))
575 .collect();
576
577 let mut verdicts = Vec::new();
578 for file in subjects {
579 let Some(w) = work.get(file) else {
580 verdicts.push(Verdict {
581 file: file.clone(),
582 findings: vec![Finding {
583 class: "MARKERS",
584 detail: "the file is not in the working tree (deleted during the merge)"
585 .to_string(),
586 suggestion: None,
587 }],
588 });
589 continue;
590 };
591 let mut findings = verify_file(
592 file,
593 base.get(file).map(String::as_str),
594 ours.get(file).map(String::as_str),
595 theirs.get(file).map(String::as_str),
596 w,
597 );
598 findings.extend(dangling(file, base, ours, theirs, work, &defined_now));
599 verdicts.push(Verdict {
600 file: file.clone(),
601 findings,
602 });
603 }
604 verdicts
605}
606
607/// Names a merge stage defined, that nothing on disk defines any more, and that
608/// the working tree still calls.
609///
610/// The rename repair is the derivable half: when the vanished name has a
611/// same-file successor that IS defined now and did not exist in base, the fix
612/// is a rename and weave can say which one.
613fn dangling(
614 file: &str,
615 base: &Tree,
616 ours: &Tree,
617 theirs: &Tree,
618 work: &Tree,
619 defined_now: &BTreeSet<String>,
620) -> Vec<Finding> {
621 let mut gone: BTreeSet<String> = BTreeSet::new();
622 for stage in [base, ours, theirs] {
623 for (p, c) in stage.iter().filter(|(p, _)| is_supported(p)) {
624 for e in entities_of(p, c) {
625 if !defined_now.contains(&e.name) {
626 gone.insert(e.name);
627 }
628 }
629 }
630 }
631 let Some(w) = work.get(file) else {
632 return Vec::new();
633 };
634 let mut hits: Vec<(String, Option<(String, String)>)> = Vec::new();
635 for name in &gone {
636 if name.len() < 3 || has_definition(w, name) || !has_call_reference(w, name) {
637 continue;
638 }
639 hits.push((name.clone(), successor_of(name, base, work)));
640 }
641 if hits.is_empty() {
642 return Vec::new();
643 }
644 let listed = hits
645 .iter()
646 .take(4)
647 .map(|(n, _)| format!("`{n}`"))
648 .collect::<Vec<_>>()
649 .join(", ");
650 let suggestion = hits.iter().find_map(|(n, s)| {
651 s.as_ref().map(|(to, where_)| {
652 format!(
653 "`{n}` left `{where_}` and `{to}` arrived in its place — if that is the \
654 rename, rewrite every call of `{n}` here to `{to}`"
655 )
656 })
657 });
658 vec![Finding {
659 class: "DANGLING",
660 detail: format!(
661 "{} name(s) are still referenced here but defined nowhere in the working tree: {}",
662 hits.len(),
663 listed
664 ),
665 suggestion: suggestion
666 .or_else(|| Some("restore the definition, or remove the call sites".to_string())),
667 }]
668}
669
670/// The name a vanished definition was RENAMED to, and the file that says so.
671///
672/// This is the derivable half of a `DANGLING` finding, and it is derived by
673/// the one matcher both weave passes already use — `sem-core`'s
674/// `match_entities`, so the answer here and the answer the merge gave cannot
675/// disagree. It is looked up in the file where the definition *lived*, not in
676/// the file where the call survives: a new name next to a broken call is a
677/// coincidence, a matched pair in the defining file is evidence.
678fn successor_of(name: &str, base: &Tree, work: &Tree) -> Option<(String, String)> {
679 for (path, before) in base.iter().filter(|(p, _)| is_supported(p)) {
680 let old = entities_of(path, before);
681 if !old.iter().any(|e| e.name == name) {
682 continue;
683 }
684 let Some(now) = work.get(path) else { continue };
685 let new = entities_of(path, now);
686 for c in match_entities(&old, &new, path, None, None, None).changes {
687 if c.change_type == ChangeType::Renamed
688 && c.old_entity_name.as_deref() == Some(name)
689 && c.entity_name != name
690 && !c.entity_name.is_empty()
691 {
692 return Some((c.entity_name, path.clone()));
693 }
694 }
695 // The matcher pairs on body similarity, so a declaration that was
696 // renamed AND rewritten comes back unpaired. One name out, one name in,
697 // in the file that defined it, is then the only candidate there is —
698 // and the suggestion says "if that is the rename" rather than claiming
699 // it, because this is arithmetic and not evidence.
700 let before: BTreeSet<&str> = old.iter().map(|e| e.name.as_str()).collect();
701 let after: BTreeSet<&str> = new.iter().map(|e| e.name.as_str()).collect();
702 let gone: Vec<&&str> = before.difference(&after).collect();
703 let arrived: Vec<&&str> = after.difference(&before).collect();
704 if gone.len() == 1 && arrived.len() == 1 && *gone[0] == name {
705 return Some((arrived[0].to_string(), path.clone()));
706 }
707 }
708 None
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 fn tree(pairs: &[(&str, &str)]) -> Tree {
716 pairs
717 .iter()
718 .map(|(p, c)| (p.to_string(), c.to_string()))
719 .collect()
720 }
721
722 const BASE: &str = "def a():\n total_count = 0\n return total_count\n\ndef keep():\n return 'a stable helper line'\n";
723
724 #[test]
725 fn a_faithful_resolution_verifies_clean() {
726 let ours = BASE.replace("total_count = 0", "total_count = 1");
727 let theirs = BASE.replace("total_count = 0", "total_count = 2");
728 let resolved = BASE.replace("total_count = 0", "total_count = 3");
729 let v = check(
730 &tree(&[("m.py", BASE)]),
731 &tree(&[("m.py", &ours)]),
732 &tree(&[("m.py", &theirs)]),
733 &tree(&[("m.py", &resolved)]),
734 &["m.py".to_string()],
735 );
736 assert!(v[0].ok(), "{:#?}", v[0]);
737 assert!(v[0].line().starts_with("OK: m.py"));
738 }
739
740 #[test]
741 fn a_deliberately_lossy_resolution_is_caught() {
742 let ours = BASE.replace("total_count = 0", "total_count = 1");
743 let theirs = BASE.replace("total_count = 0", "total_count = 2");
744 // Drops `keep`, which NEITHER side touched — the unanimous case.
745 let lossy = "def a():\n total_count = 3\n return total_count\n";
746 let v = check(
747 &tree(&[("m.py", BASE)]),
748 &tree(&[("m.py", &ours)]),
749 &tree(&[("m.py", &theirs)]),
750 &tree(&[("m.py", lossy)]),
751 &["m.py".to_string()],
752 );
753 assert!(!v[0].ok(), "{:#?}", v[0]);
754 assert!(
755 v[0].findings.iter().any(|f| f.class == "LOSS"),
756 "{:#?}",
757 v[0]
758 );
759 }
760
761 #[test]
762 fn markers_left_behind_are_the_first_thing_reported() {
763 let conflicted = "def a():\n<<<<<<< ours — function `a`\n total_count = 1\n=======\n total_count = 2\n>>>>>>> theirs — function `a`\n return total_count\n\ndef keep():\n return 'a stable helper line'\n";
764 let v = check(
765 &tree(&[("m.py", BASE)]),
766 &tree(&[("m.py", BASE)]),
767 &tree(&[("m.py", BASE)]),
768 &tree(&[("m.py", conflicted)]),
769 &["m.py".to_string()],
770 );
771 assert_eq!(v[0].findings[0].class, "MARKERS", "{:#?}", v[0]);
772 }
773
774 #[test]
775 fn a_line_stated_more_often_than_either_side_states_it_is_a_duplicate() {
776 let dup = "def a():\n total_count = 0\n total_count = 0\n return total_count\n\ndef keep():\n return 'a stable helper line'\n";
777 let v = check(
778 &tree(&[("m.py", BASE)]),
779 &tree(&[("m.py", BASE)]),
780 &tree(&[("m.py", BASE)]),
781 &tree(&[("m.py", dup)]),
782 &["m.py".to_string()],
783 );
784 assert!(
785 v[0].findings.iter().any(|f| f.class == "DUP"),
786 "{:#?}",
787 v[0]
788 );
789 }
790
791 #[test]
792 fn a_call_whose_definition_left_the_repo_is_dangling() {
793 let base = tree(&[
794 ("a.py", "def fetch_user(i):\n return i\n"),
795 ("b.py", "def go():\n return fetch_user(1)\n"),
796 ]);
797 let ours = tree(&[
798 ("a.py", "def get_user(i):\n return i\n"),
799 ("b.py", "def go():\n return fetch_user(1)\n"),
800 ]);
801 let work = ours.clone();
802 let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
803 assert!(
804 v[0].findings.iter().any(|f| f.class == "DANGLING"),
805 "{:#?}",
806 v[0]
807 );
808 }
809
810 #[test]
811 fn a_report_with_no_files_says_so_in_a_sentence_and_never_prints_nothing() {
812 let r = Report {
813 scope: "no merge in progress".to_string(),
814 verdicts: Vec::new(),
815 };
816 let text = r.render();
817 assert!(text.contains("NOTHING WAS VERIFIED"), "{text}");
818 assert!(!text.trim().is_empty());
819 assert!(!text.trim().starts_with('['));
820 }
821}