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 advisory about one file — a fact weave surfaces without deciding it. It
129/// is NOT a finding: it never makes a verdict `FOUND`, never counts in the
130/// tally, and never changes the exit code. It rides beside the verdict as
131/// something a reviewer may want to glance at, not something they must fix.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Advisory {
134    /// The container the co-change happened in.
135    pub entity: String,
136    pub entity_type: String,
137    /// The one-line human reading, e.g. `both sides changed siblings (ours
138    /// added `bar`; theirs changed `baz`)`.
139    pub detail: String,
140}
141
142/// One file's verdict. An empty `findings` is the OK verdict and prints as a
143/// sentence. `advisories` are separate: a file may be OK and still carry them.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct Verdict {
146    pub file: String,
147    pub findings: Vec<Finding>,
148    pub advisories: Vec<Advisory>,
149}
150
151impl Verdict {
152    /// OK is about FINDINGS only. Advisories never make a file not-OK — that is
153    /// the whole point of the register.
154    pub fn ok(&self) -> bool {
155        self.findings.is_empty()
156    }
157
158    /// The one line this file gets.
159    pub fn line(&self) -> String {
160        if self.ok() {
161            return format!(
162                "OK: {} — markers cleared, no unanimous-line loss, no duplicated \
163                 definitions or lines, no dangling references",
164                self.file
165            );
166        }
167        let details: Vec<&str> = self.findings.iter().map(|f| f.detail.as_str()).collect();
168        format!("FOUND: {} — {}", self.file, details.join("; "))
169    }
170}
171
172/// Everything one run of the working-tree check looked at and concluded.
173///
174/// The *scope* travels with the verdicts on purpose: "no findings" and "no
175/// files" are different answers and a reader must never have to infer which
176/// one they got.
177#[derive(Debug, Clone)]
178pub struct Report {
179    /// What was compared, in words — `HEAD × MERGE_HEAD`, or why nothing was.
180    pub scope: String,
181    pub verdicts: Vec<Verdict>,
182}
183
184impl Report {
185    /// (files verified clean, files with findings, findings). One pass, one
186    /// answer: the summary sentence and the exit code are two readings of the
187    /// same tally, and computing them separately is how they drift.
188    pub fn tally(&self) -> (usize, usize, usize) {
189        let mut clean = 0;
190        let mut dirty = 0;
191        let mut findings = 0;
192        for v in &self.verdicts {
193            if v.ok() {
194                clean += 1;
195            } else {
196                dirty += 1;
197            }
198            findings += v.findings.len();
199        }
200        (clean, dirty, findings)
201    }
202
203    /// The whole report as the text a reader gets. Never empty, ever.
204    pub fn render(&self) -> String {
205        let mut out = format!("weave check: {}\n", self.scope);
206        if self.verdicts.is_empty() {
207            out.push_str(
208                "weave check: NOTHING WAS VERIFIED. This is not a clean bill of health — \
209                 weave had no file to look at.\n",
210            );
211            return out;
212        }
213        for v in &self.verdicts {
214            out.push_str(&v.line());
215            out.push('\n');
216            for f in &v.findings {
217                if let Some(s) = &f.suggestion {
218                    out.push_str(&format!("    suggestion ({}): {}\n", f.class, s));
219                }
220            }
221            // Advisories print UNDER the verdict, tagged `review`, whether or not
222            // the file is OK. They are not problems — they carry no exit code and
223            // no "FOUND" — so they read as a glance, not a task.
224            for a in &v.advisories {
225                out.push_str(&format!(
226                    "    review (COOCCUPANCY): cleanly merged {} `{}` — {}; confirm they are meant to coexist\n",
227                    a.entity_type, a.entity, a.detail
228                ));
229            }
230        }
231        let (good, bad, findings) = self.tally();
232        if bad == 0 {
233            out.push_str(&format!(
234                "weave check: {good} file(s) verified against the three merge stages — \
235                 every line both sides kept is present, nothing is stated more often than \
236                 either side stated it, every reference still resolves. Re-reading these \
237                 files end to end will not find anything this did not.\n"
238            ));
239        } else {
240            out.push_str(&format!(
241                "weave check: {good} file(s) clean, {bad} file(s) with findings \
242                 ({findings} total). Fix the files above; the clean ones need no further \
243                 reading.\n"
244            ));
245        }
246        out
247    }
248}
249
250/// The four checks, over one file.
251///
252/// `work` is the bytes on disk. `base` / `ours` / `theirs` are the three merge
253/// stages — the only reference frame in which "loss" and "duplicate" mean
254/// anything, because both are statements about what the two developers agreed.
255fn verify_file(
256    file: &str,
257    base: Option<&str>,
258    ours: Option<&str>,
259    theirs: Option<&str>,
260    work: &str,
261) -> Vec<Finding> {
262    let mut out = Vec::new();
263
264    // ---- 1. Markers -------------------------------------------------------
265    let marker_lines = weave_core::frame::marker_line_count(work);
266    if marker_lines > 0 {
267        out.push(Finding {
268            class: "MARKERS",
269            detail: format!("{marker_lines} conflict marker line(s) still present"),
270            suggestion: Some(
271                "the file is not resolved — every opening, separator and closing marker \
272                 line must go, along with the refused_by: comment inside each box"
273                    .to_string(),
274            ),
275        });
276    }
277    if work.lines().any(weave_core::conflict::is_teach_line) {
278        out.push(Finding {
279            class: "MARKERS",
280            detail: "weave's teach line is still in the file".to_string(),
281            suggestion: Some(
282                "delete the trailing `weave: run 'weave explain …'` comment; it is marker \
283                 furniture, not source"
284                    .to_string(),
285            ),
286        });
287    }
288
289    // ---- 2. Unanimous loss ------------------------------------------------
290    //
291    // A line both sides kept is a line neither developer asked to remove. That
292    // is the one class where "the resolver chose differently" is not a defence.
293    //
294    // The bound is base-relative and additive, because deletions from the two
295    // sides are: of base's `n` copies, ours kept `min(n, ours)` and theirs kept
296    // `min(n, theirs)`, and nothing says those are the same copies, so the
297    // survivors are the sum minus `n`. `min(ours, theirs)` — what this used to
298    // ask — is a condition the *developer's own resolution* fails wherever the
299    // two sides deleted different copies of a repeated line.
300    if let (Some(b), Some(o), Some(t)) = (base, ours, theirs) {
301        let (cb, co, ct, cw) = (counts(b), counts(o), counts(t), counts(work));
302        let mut lost: Vec<(&str, usize)> = cb
303            .iter()
304            .filter_map(|(line, n)| {
305                let kept_o = co.get(line).copied().unwrap_or(0).min(*n);
306                let kept_t = ct.get(line).copied().unwrap_or(0).min(*n);
307                // Both sides may between them have deleted every copy; that is
308                // a fact about the input, not an underflow to repair.
309                // `saturating_sub` would read the same and clippy asks for it,
310                // but the analyzer that looks for silent-repair patterns would
311                // flag it as one — and this is not a repair, it is the
312                // arithmetic. Written out, both checkers are told the truth.
313                #[allow(clippy::implicit_saturating_sub)]
314                let required = if kept_o + kept_t > *n {
315                    kept_o + kept_t - *n
316                } else {
317                    0
318                };
319                let found = cw.get(line).copied().unwrap_or(0);
320                // `then`, not `then_some`: the argument of `then_some` is
321                // evaluated whether or not the condition holds, and
322                // `required - found` underflows on every line that is NOT lost
323                // — which is almost all of them.
324                (found < required).then(|| (*line, required - found))
325            })
326            .collect();
327        lost.sort();
328        if !lost.is_empty() {
329            let total: usize = lost.iter().map(|(_, n)| n).sum();
330            out.push(Finding {
331                class: "LOSS",
332                detail: format!(
333                    "{total} line(s) BOTH sides kept are missing, e.g. `{}`",
334                    clip(lost[0].0)
335                ),
336                suggestion: Some(format!(
337                    "restore the {} distinct line(s) neither side deleted; nothing in this \
338                     merge licenses dropping them",
339                    lost.len()
340                )),
341            });
342        }
343    }
344
345    // ---- 3. Duplication ---------------------------------------------------
346    //
347    // Same ruler as the frame check: a line may appear as often as the side
348    // that says it most says it, and no oftener. This is checked over the
349    // whole file and not only over the frame because git folds one side's
350    // rename into "clean" context and the declaration lands twice, with no
351    // marker anywhere near it.
352    {
353        let cw = counts(work);
354        let (cb, co, ct) = (
355            counts(base.unwrap_or("")),
356            counts(ours.unwrap_or("")),
357            counts(theirs.unwrap_or("")),
358        );
359        // The ceiling `frame_duplicates` already uses. Additions from the two
360        // sides are additive exactly as deletions are, so a resolution that
361        // keeps both sides' new copy of a line is inside its budget — and at
362        // least one copy is always allowed, because a line the resolver wrote
363        // itself is allowed to exist. Twice is the question.
364        let allowance = |line: &str| {
365            let (n, o, t) = (
366                cb.get(line).copied().unwrap_or(0),
367                co.get(line).copied().unwrap_or(0),
368                ct.get(line).copied().unwrap_or(0),
369            );
370            // Written out rather than saturating, for the same reason the
371            // unanimity floor above is: both sides may between them have kept
372            // fewer copies than base wrote, and that is the arithmetic, not an
373            // underflow to repair.
374            #[allow(clippy::implicit_saturating_sub)]
375            let additive = if o + t > n { o + t - n } else { 0 };
376            additive.max(o).max(t).max(1)
377        };
378        let over: BTreeSet<&str> = cw
379            .iter()
380            .filter(|(line, found)| **found > allowance(line))
381            .map(|(line, _)| *line)
382            .collect();
383        // A fragment is only evidence in company: a doubled edit lands as a
384        // RUN of over-budget lines, whereas a call argument that repeats on its
385        // own is how the same call shape gets written twice. Adjacency is asked
386        // of the working tree, because that is where the run would be.
387        let mut in_a_run: BTreeSet<&str> = BTreeSet::new();
388        {
389            let lines: Vec<&str> = work.lines().map(str::trim).collect();
390            for pair in lines.windows(2) {
391                if over.contains(pair[0]) && over.contains(pair[1]) {
392                    in_a_run.insert(pair[0]);
393                    in_a_run.insert(pair[1]);
394                }
395            }
396        }
397        let mut dup: Vec<(&str, usize, usize)> = over
398            .iter()
399            .filter(|line| carries_identity(line) || in_a_run.contains(*line))
400            .map(|line| (*line, cw[line], allowance(line)))
401            .collect();
402        dup.sort();
403        if !dup.is_empty() {
404            out.push(Finding {
405                class: "DUP",
406                detail: format!(
407                    "{} line(s) appear more often than any version states them, e.g. `{}` \
408                     ({}x, at most {}x in base/ours/theirs)",
409                    dup.len(),
410                    clip(dup[0].0),
411                    dup[0].1,
412                    dup[0].2
413                ),
414                suggestion: Some(
415                    "delete the extra copies — a line stated twice usually means one side's \
416                     edit landed both inside and outside a marker"
417                        .to_string(),
418                ),
419            });
420        }
421    }
422
423    // Duplicate DEFINITIONS are worth their own finding: two `def f` in one
424    // file is a language-level bug, not a stylistic repeat, and the second one
425    // silently wins. Which is why the *identity* has to be the language's and
426    // not the name string's — see [`definition_key`].
427    let mut seen: BTreeMap<DefinitionKey, (String, usize)> = BTreeMap::new();
428    for e in entities_of(file, work) {
429        let Some(key) = definition_key(file, &e) else {
430            continue;
431        };
432        let slot = seen.entry(key).or_insert_with(|| (e.name.clone(), 0));
433        slot.1 += 1;
434    }
435    let dup_defs: Vec<&(String, usize)> = seen.values().filter(|(_, n)| *n > 1).collect();
436    if !dup_defs.is_empty() {
437        out.push(Finding {
438            class: "DUP",
439            detail: format!(
440                "{} name(s) are defined more than once: {}",
441                dup_defs.len(),
442                dup_defs
443                    .iter()
444                    .take(4)
445                    .map(|(n, c)| format!("`{n}` ({c}x)"))
446                    .collect::<Vec<_>>()
447                    .join(", ")
448            ),
449            suggestion: Some(
450                "keep one definition of each name; the later one shadows the earlier and \
451                 the earlier one's edits are silently dead"
452                    .to_string(),
453            ),
454        });
455    }
456
457    out
458}
459
460/// What makes two top-level items the SAME declaration: the kind, the name with
461/// its generic arguments off, and — where the language overloads — the
462/// parameter list.
463type DefinitionKey = (String, String, Option<String>);
464
465/// Kinds that do not declare a name of their own.
466///
467/// Three groups, and each one is a false positive the v3 trial paid for:
468///
469/// * **attachments** — `impl`, `extension`, `instance`. `impl X` does not
470///   define `X`; it adds to the `X` a declaration elsewhere already made, and
471///   a type may have as many inherent impls as it likes. This is the rule
472///   whose absence made `struct UpdateDiff` + `impl UpdateDiff` read as
473///   "`UpdateDiff` defined 2x" on a resolution that was correct.
474/// * **reopenable declarations** — a namespace, a module or a TypeScript
475///   interface stated twice is declaration *merging*, which is the language
476///   working as designed, not a name silently shadowed.
477/// * **calls that are not declarations at all** — `test('…')` / `it('…')` /
478///   `describe('…')` are function calls whose first argument the extractor
479///   uses as a name. Two tests may share a title; nothing shadows anything.
480fn declares_a_name(entity_type: &str) -> bool {
481    !matches!(
482        entity_type,
483        "impl"
484            | "extension"
485            | "instance"
486            | "module"
487            | "internal_module"
488            | "namespace"
489            | "interface"
490            | "test"
491            | "test_suite"
492            | "export"
493            | "import"
494            | "package"
495            | "use"
496    )
497}
498
499/// Languages where two items may share a name and differ only in their
500/// parameters. In these, a repeated name is an overload until the signatures
501/// match; everywhere else a repeated name is a redefinition.
502fn overloads(path: &str) -> bool {
503    matches!(
504        path.rsplit('.').next().unwrap_or(""),
505        "java"
506            | "kt"
507            | "kts"
508            | "cs"
509            | "cpp"
510            | "cc"
511            | "cxx"
512            | "hpp"
513            | "hh"
514            | "h"
515            | "c"
516            | "ts"
517            | "tsx"
518            | "swift"
519            | "scala"
520            | "php"
521    )
522}
523
524/// `Bar<T>` and `Bar<U>` are one nameable thing; the generic arguments are the
525/// item's parameters, not part of its name.
526fn without_generics(name: &str) -> &str {
527    match name.find('<') {
528        Some(i) if i > 0 => name[..i].trim_end(),
529        _ => name,
530    }
531}
532
533/// The parameter list of an item's header, whitespace removed — `None` when the
534/// item has no parameter list to compare (a struct, an enum, a constant).
535fn parameter_list(content: &str) -> Option<String> {
536    let header: String = content.lines().take(3).collect::<Vec<_>>().join(" ");
537    let open = header.find('(')?;
538    let mut depth = 0usize;
539    for (i, c) in header[open..].char_indices() {
540        match c {
541            '(' => depth += 1,
542            ')' => {
543                depth -= 1;
544                if depth == 0 {
545                    return Some(
546                        header[open + 1..open + i]
547                            .chars()
548                            .filter(|c| !c.is_whitespace())
549                            .collect(),
550                    );
551                }
552            }
553            _ => {}
554        }
555    }
556    None
557}
558
559/// The identity a duplicate-definition finding is entitled to compare, or
560/// `None` for an item that declares nothing.
561fn definition_key(path: &str, e: &SemanticEntity) -> Option<DefinitionKey> {
562    if e.name.is_empty() || !declares_a_name(&e.entity_type) {
563        return None;
564    }
565    let signature = overloads(path)
566        .then(|| parameter_list(&e.content))
567        .flatten();
568    Some((
569        e.entity_type.clone(),
570        without_generics(&e.name).to_string(),
571        signature,
572    ))
573}
574
575fn clip(l: &str) -> String {
576    let t = l.trim();
577    if t.chars().count() > 72 {
578        t.chars().take(71).chain(['…']).collect()
579    } else {
580        t.to_string()
581    }
582}
583
584/// Verify a whole working tree against the three merge stages.
585///
586/// `work` holds the bytes on disk for every supported file — the dangling pass
587/// needs the *repo*, not the file, because a definition deleted in `a.py` with
588/// its caller in `b.py` is invisible to any per-file rule.
589pub fn check(
590    base: &Tree,
591    ours: &Tree,
592    theirs: &Tree,
593    work: &Tree,
594    subjects: &[String],
595) -> Vec<Verdict> {
596    // Names still defined anywhere on disk. Repo-wide on purpose: a name a
597    // subject still calls is bound — not dangling — the moment ANY file, touched
598    // or not, still defines it, so the suppression set cannot be scoped to the
599    // subjects the way the stage trees are.
600    let defined_now: BTreeSet<String> = work
601        .iter()
602        .filter(|(p, _)| is_supported(p))
603        .flat_map(|(p, c)| entities_of(p, c).into_iter().map(|e| e.name))
604        .collect();
605
606    // The names a merge stage defined that nothing on disk defines any more.
607    // This depends on the stages and the working tree, NOT on which subject we
608    // are looking at, so it is computed ONCE. Folding it into the per-file loop
609    // — as this once did — re-parses every stage of every file for every subject,
610    // an O(subjects × repo) cost that is the whole reason a large mid-merge tree
611    // hangs. The stage trees are already scoped to the subjects (see
612    // [`crate::gitscan::merge_scope`]); an untouched file's stage equals its
613    // working-tree copy, so it contributes only names that are in `defined_now`
614    // and can never be `gone`.
615    let mut gone: BTreeSet<String> = BTreeSet::new();
616    for stage in [base, ours, theirs] {
617        for (p, c) in stage.iter().filter(|(p, _)| is_supported(p)) {
618            for e in entities_of(p, c) {
619                if !defined_now.contains(&e.name) {
620                    gone.insert(e.name);
621                }
622            }
623        }
624    }
625
626    let mut verdicts = Vec::new();
627    for file in subjects {
628        let Some(w) = work.get(file) else {
629            verdicts.push(Verdict {
630                file: file.clone(),
631                findings: vec![Finding {
632                    class: "MARKERS",
633                    detail: "the file is not in the working tree (deleted during the merge)"
634                        .to_string(),
635                    suggestion: None,
636                }],
637                advisories: Vec::new(),
638            });
639            continue;
640        };
641        let mut findings = verify_file(
642            file,
643            base.get(file).map(String::as_str),
644            ours.get(file).map(String::as_str),
645            theirs.get(file).map(String::as_str),
646            w,
647        );
648        findings.extend(dangling(w, base, work, &gone));
649        let advisories = advisories_for(
650            file,
651            base.get(file).map(String::as_str),
652            ours.get(file).map(String::as_str),
653            theirs.get(file).map(String::as_str),
654        );
655        verdicts.push(Verdict {
656            file: file.clone(),
657            findings,
658            advisories,
659        });
660    }
661    verdicts
662}
663
664/// The co-occupancy advisories weave's own merge of the three stages would
665/// raise: containers both sides changed different siblings of, which merged
666/// clean. Read off the three stage texts, not off the bytes on disk — the fact
667/// is about what the two authors did, and holds however the resolution was
668/// written. Empty when any stage lacks the file (an add or a delete is not a
669/// co-change) or nothing on disk parses it.
670fn advisories_for(
671    file: &str,
672    base: Option<&str>,
673    ours: Option<&str>,
674    theirs: Option<&str>,
675) -> Vec<Advisory> {
676    let (Some(b), Some(o), Some(t)) = (base, ours, theirs) else {
677        return Vec::new();
678    };
679    weave_core::entity_merge(b, o, t, file)
680        .warnings
681        .iter()
682        .filter_map(|w| match &w.kind {
683            weave_core::validate::WarningKind::SiblingCoChange {
684                ours_added,
685                ours_changed,
686                theirs_added,
687                theirs_changed,
688            } => Some(Advisory {
689                entity: w.entity_name.clone(),
690                entity_type: w.entity_type.clone(),
691                detail: format!(
692                    "both sides changed siblings ({}; {})",
693                    weave_core::validate::co_change_side_phrase("ours", ours_added, ours_changed),
694                    weave_core::validate::co_change_side_phrase(
695                        "theirs",
696                        theirs_added,
697                        theirs_changed
698                    ),
699                ),
700            }),
701            _ => None,
702        })
703        .collect()
704}
705
706/// The subset of the repo-wide vanished-name set (`gone`) that THIS file still
707/// calls without defining — the file's dangling references.
708///
709/// `gone` is computed once by the caller and shared across every subject,
710/// because it is a fact about the merge, not about the file being verified. Only
711/// the per-file half lives here: which of those vanished names this file's bytes
712/// actually call.
713///
714/// The rename repair is the derivable half: when the vanished name has a
715/// same-file successor that IS defined now and did not exist in base, the fix
716/// is a rename and weave can say which one.
717fn dangling(w: &str, base: &Tree, work: &Tree, gone: &BTreeSet<String>) -> Vec<Finding> {
718    let mut hits: Vec<(String, Option<(String, String)>)> = Vec::new();
719    for name in gone {
720        if name.len() < 3 || has_definition(w, name) || !has_call_reference(w, name) {
721            continue;
722        }
723        hits.push((name.clone(), successor_of(name, base, work)));
724    }
725    if hits.is_empty() {
726        return Vec::new();
727    }
728    let listed = hits
729        .iter()
730        .take(4)
731        .map(|(n, _)| format!("`{n}`"))
732        .collect::<Vec<_>>()
733        .join(", ");
734    let suggestion = hits.iter().find_map(|(n, s)| {
735        s.as_ref().map(|(to, where_)| {
736            format!(
737                "`{n}` left `{where_}` and `{to}` arrived in its place — if that is the \
738                 rename, rewrite every call of `{n}` here to `{to}`"
739            )
740        })
741    });
742    vec![Finding {
743        class: "DANGLING",
744        detail: format!(
745            "{} name(s) are still referenced here but defined nowhere in the working tree: {}",
746            hits.len(),
747            listed
748        ),
749        suggestion: suggestion
750            .or_else(|| Some("restore the definition, or remove the call sites".to_string())),
751    }]
752}
753
754/// The name a vanished definition was RENAMED to, and the file that says so.
755///
756/// This is the derivable half of a `DANGLING` finding, and it is derived by
757/// the one matcher both weave passes already use — `sem-core`'s
758/// `match_entities`, so the answer here and the answer the merge gave cannot
759/// disagree. It is looked up in the file where the definition *lived*, not in
760/// the file where the call survives: a new name next to a broken call is a
761/// coincidence, a matched pair in the defining file is evidence.
762fn successor_of(name: &str, base: &Tree, work: &Tree) -> Option<(String, String)> {
763    for (path, before) in base.iter().filter(|(p, _)| is_supported(p)) {
764        let old = entities_of(path, before);
765        if !old.iter().any(|e| e.name == name) {
766            continue;
767        }
768        let Some(now) = work.get(path) else { continue };
769        let new = entities_of(path, now);
770        for c in match_entities(&old, &new, path, None, None, None).changes {
771            if c.change_type == ChangeType::Renamed
772                && c.old_entity_name.as_deref() == Some(name)
773                && c.entity_name != name
774                && !c.entity_name.is_empty()
775            {
776                return Some((c.entity_name, path.clone()));
777            }
778        }
779        // The matcher pairs on body similarity, so a declaration that was
780        // renamed AND rewritten comes back unpaired. One name out, one name in,
781        // in the file that defined it, is then the only candidate there is —
782        // and the suggestion says "if that is the rename" rather than claiming
783        // it, because this is arithmetic and not evidence.
784        let before: BTreeSet<&str> = old.iter().map(|e| e.name.as_str()).collect();
785        let after: BTreeSet<&str> = new.iter().map(|e| e.name.as_str()).collect();
786        let gone: Vec<&&str> = before.difference(&after).collect();
787        let arrived: Vec<&&str> = after.difference(&before).collect();
788        if gone.len() == 1 && arrived.len() == 1 && *gone[0] == name {
789            return Some((arrived[0].to_string(), path.clone()));
790        }
791    }
792    None
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    fn tree(pairs: &[(&str, &str)]) -> Tree {
800        pairs
801            .iter()
802            .map(|(p, c)| (p.to_string(), c.to_string()))
803            .collect()
804    }
805
806    const BASE: &str = "def a():\n    total_count = 0\n    return total_count\n\ndef keep():\n    return 'a stable helper line'\n";
807
808    #[test]
809    fn a_faithful_resolution_verifies_clean() {
810        let ours = BASE.replace("total_count = 0", "total_count = 1");
811        let theirs = BASE.replace("total_count = 0", "total_count = 2");
812        let resolved = BASE.replace("total_count = 0", "total_count = 3");
813        let v = check(
814            &tree(&[("m.py", BASE)]),
815            &tree(&[("m.py", &ours)]),
816            &tree(&[("m.py", &theirs)]),
817            &tree(&[("m.py", &resolved)]),
818            &["m.py".to_string()],
819        );
820        assert!(v[0].ok(), "{:#?}", v[0]);
821        assert!(v[0].line().starts_with("OK: m.py"));
822    }
823
824    #[test]
825    fn a_deliberately_lossy_resolution_is_caught() {
826        let ours = BASE.replace("total_count = 0", "total_count = 1");
827        let theirs = BASE.replace("total_count = 0", "total_count = 2");
828        // Drops `keep`, which NEITHER side touched — the unanimous case.
829        let lossy = "def a():\n    total_count = 3\n    return total_count\n";
830        let v = check(
831            &tree(&[("m.py", BASE)]),
832            &tree(&[("m.py", &ours)]),
833            &tree(&[("m.py", &theirs)]),
834            &tree(&[("m.py", lossy)]),
835            &["m.py".to_string()],
836        );
837        assert!(!v[0].ok(), "{:#?}", v[0]);
838        assert!(
839            v[0].findings.iter().any(|f| f.class == "LOSS"),
840            "{:#?}",
841            v[0]
842        );
843    }
844
845    #[test]
846    fn markers_left_behind_are_the_first_thing_reported() {
847        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";
848        let v = check(
849            &tree(&[("m.py", BASE)]),
850            &tree(&[("m.py", BASE)]),
851            &tree(&[("m.py", BASE)]),
852            &tree(&[("m.py", conflicted)]),
853            &["m.py".to_string()],
854        );
855        assert_eq!(v[0].findings[0].class, "MARKERS", "{:#?}", v[0]);
856    }
857
858    #[test]
859    fn a_line_stated_more_often_than_either_side_states_it_is_a_duplicate() {
860        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";
861        let v = check(
862            &tree(&[("m.py", BASE)]),
863            &tree(&[("m.py", BASE)]),
864            &tree(&[("m.py", BASE)]),
865            &tree(&[("m.py", dup)]),
866            &["m.py".to_string()],
867        );
868        assert!(
869            v[0].findings.iter().any(|f| f.class == "DUP"),
870            "{:#?}",
871            v[0]
872        );
873    }
874
875    #[test]
876    fn a_call_whose_definition_left_the_repo_is_dangling() {
877        let base = tree(&[
878            ("a.py", "def fetch_user(i):\n    return i\n"),
879            ("b.py", "def go():\n    return fetch_user(1)\n"),
880        ]);
881        let ours = tree(&[
882            ("a.py", "def get_user(i):\n    return i\n"),
883            ("b.py", "def go():\n    return fetch_user(1)\n"),
884        ]);
885        let work = ours.clone();
886        let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
887        assert!(
888            v[0].findings.iter().any(|f| f.class == "DANGLING"),
889            "{:#?}",
890            v[0]
891        );
892    }
893
894    #[test]
895    fn a_clean_container_co_change_is_an_advisory_not_a_finding() {
896        // Both sides change DIFFERENT methods of one class; the resolution on
897        // disk keeps both. The file is OK — no findings — yet the co-occupancy
898        // is surfaced as a non-blocking review advisory.
899        let base =
900            "class C:\n    def a(self):\n        return 1\n\n    def b(self):\n        return 2\n";
901        let ours = "class C:\n    def a(self):\n        return 1 + 10\n\n    def b(self):\n        return 2\n";
902        let theirs = "class C:\n    def a(self):\n        return 1\n\n    def b(self):\n        return 2 + 20\n";
903        let work = "class C:\n    def a(self):\n        return 1 + 10\n\n    def b(self):\n        return 2 + 20\n";
904        let v = check(
905            &tree(&[("m.py", base)]),
906            &tree(&[("m.py", ours)]),
907            &tree(&[("m.py", theirs)]),
908            &tree(&[("m.py", work)]),
909            &["m.py".to_string()],
910        );
911        assert!(
912            v[0].ok(),
913            "an advisory must not make the file not-OK: {:#?}",
914            v[0]
915        );
916        assert!(
917            v[0].findings.is_empty(),
918            "advisory is not a finding: {:#?}",
919            v[0]
920        );
921        assert_eq!(
922            v[0].advisories.len(),
923            1,
924            "the co-change must be advised: {:#?}",
925            v[0]
926        );
927        assert_eq!(v[0].advisories[0].entity, "C");
928        // The report renders it under `review`, and the tally/exit are untouched.
929        let report = Report {
930            scope: "test".to_string(),
931            verdicts: v,
932        };
933        assert_eq!(report.tally().1, 0, "no files with findings");
934        assert!(
935            report.render().contains("review (COOCCUPANCY)"),
936            "{}",
937            report.render()
938        );
939    }
940
941    #[test]
942    fn a_report_with_no_files_says_so_in_a_sentence_and_never_prints_nothing() {
943        let r = Report {
944            scope: "no merge in progress".to_string(),
945            verdicts: Vec::new(),
946        };
947        let text = r.render();
948        assert!(text.contains("NOTHING WAS VERIFIED"), "{text}");
949        assert!(!text.trim().is_empty());
950        assert!(!text.trim().starts_with('['));
951    }
952}