Skip to main content

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}