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//!    or used under a name this file no longer imports? Binding evidence,
21//!    repo-wide, the same functions the per-file pass uses.
22//!
23//! Every verdict is stated as a **sentence**, never as an empty array. A
24//! reader who gets back a bare `[]` reasonably takes it for approval; a
25//! channel whose "nothing found" and "nothing looked at" render identically
26//! has a failure mode where silence is indistinguishable from a clean bill of
27//! health.
28//!
29//! ## Why the bounds below are the ones they are
30//!
31//! A verifier that replaces a manual pass is only worth its calls if its
32//! findings are evidence. Every rule here is therefore stated as a *necessary*
33//! condition on a correct resolution — one the developer's own committed
34//! resolution satisfies — and the three ways that can be got wrong are all
35//! ways this file once got them wrong:
36//!
37//! * **The unanimity floor is base-relative and additive.** `min(ours, theirs)`
38//!   is what a merge must keep only if the two sides always delete the *same*
39//!   copies of a repeated line, which on real code they never do. The bound is
40//!   `min(n, ours) + min(n, theirs) − n` over base's `n` copies, floored at
41//!   zero — the predicate [`weave_core::container`] enforces inside the merge.
42//!   Two owners of one rule is one owner too many, so this states it the same
43//!   way rather than a second way.
44//! * **The duplication ceiling is additive too.** Two sides that each add a
45//!   copy of the same line license a resolution that has one *more* copy than
46//!   either side does; `max(base, ours, theirs)` calls every such union a
47//!   duplicate. The ceiling is the one [`weave_core::frame::frame_duplicates`]
48//!   already uses.
49//! * **Multiplicity is only evidence for a line that has an identity.**
50//!   `frame` says this for structural punctuation. It is equally true of a
51//!   continuation fragment — `anyInt(),`, `await fire_event(` — which is not a
52//!   statement but a slice of one, and repeats wherever the same call shape is
53//!   written. A fragment counts only when it is part of a *run* of over-budget
54//!   lines, which is what a doubled edit actually looks like.
55//!
56//! and one that is about names rather than lines:
57//!
58//! * **A declaration and its impl blocks are ONE nameable thing.** Rust's
59//!   `struct X` plus `impl X` is not `X` defined twice; neither is a namespace
60//!   reopened, an interface merged, two `test('…')` calls sharing a title, or
61//!   two overloads sharing a name. Identity is (kind, name, signature) with the
62//!   kinds that *attach* to a declaration excluded — not the bare name.
63
64use std::collections::{BTreeMap, BTreeSet, HashMap};
65
66use sem_core::model::change::ChangeType;
67use sem_core::model::entity::SemanticEntity;
68use sem_core::model::identity::match_entities;
69
70use crate::parsers::{defined_names_of, entities_of, is_code, is_supported, oversize};
71use crate::repo_scope::Tree;
72use weave_core::binding::{
73    has_binding, has_call_reference, has_declaration, has_value_reference, identifiers,
74    import_bindings, may_mention,
75};
76// The line and declaration rulers are the merge's own ([`weave_core::verify`]):
77// the driver refuses to call a merge clean on exactly the evidence this check
78// reports, so the two cannot drift apart. The one difference — a person may
79// write a line no side wrote — is `duplication_ceiling`'s parameter.
80use weave_core::verify::{
81    carries_identity, definition_key, duplication_ceiling, line_counts as counts, significant,
82    unanimity_floor, DefinitionKey,
83};
84
85/// One finding about one file, already in the words the reader gets.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Finding {
88    /// `MARKERS` | `LOSS` | `DUP` | `PARSE` | `DANGLING` | `MODDEL` |
89    /// `MISSING` | `UNREAD`.
90    pub class: &'static str,
91    pub detail: String,
92    /// A repair that follows mechanically from the finding, when one does.
93    /// `None` is honest silence, not an empty string.
94    pub suggestion: Option<String>,
95}
96
97/// One advisory about one file — a fact weave surfaces without deciding it. It
98/// is NOT a finding: it never makes a verdict `FOUND`, never counts in the
99/// tally, and never changes the exit code. It rides beside the verdict as
100/// something a reviewer may want to glance at, not something they must fix.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Advisory {
103    /// `COOCCUPANCY` (siblings both sides changed, merged clean) | `MODDEL`
104    /// (a modify/delete resolved in a way only the authors' intent can judge).
105    pub class: &'static str,
106    /// The container the co-change happened in; for `MODDEL`, the file.
107    pub entity: String,
108    pub entity_type: String,
109    /// The one-line human reading, e.g. `both sides changed siblings (ours
110    /// added `bar`; theirs changed `baz`)`.
111    pub detail: String,
112}
113
114/// One file's verdict. An empty `findings` is the OK verdict and prints as a
115/// sentence. `advisories` are separate: a file may be OK and still carry them.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Verdict {
118    pub file: String,
119    pub findings: Vec<Finding>,
120    pub advisories: Vec<Advisory>,
121    /// Why an OK file was not read line by line — it is not in the working
122    /// tree as the merge asked, or it is not source. `None` for a file that
123    /// was checked.
124    pub note: Option<String>,
125}
126
127impl Verdict {
128    /// An OK verdict for a file there was nothing to verify in, saying why.
129    /// Every file the merge touched gets a line; silence about one would read
130    /// the same as approval of it.
131    pub fn noted(file: &str, note: &str) -> Verdict {
132        Verdict {
133            file: file.to_string(),
134            findings: Vec::new(),
135            advisories: Vec::new(),
136            note: Some(note.to_string()),
137        }
138    }
139
140    /// The verdict for a file whose merge stage could not be read: NOT
141    /// checked, and said so. Leaving it out of the report would make it
142    /// indistinguishable from a file nobody asked about.
143    pub fn unread(file: &str, why: &str) -> Verdict {
144        Verdict {
145            file: file.to_string(),
146            findings: vec![Finding {
147                class: "UNREAD",
148                detail: format!("NOT CHECKED — a merge stage could not be read ({why})"),
149                suggestion: Some(
150                    "fetch the missing objects (in a partial clone: `git fetch` from the \
151                     promisor remote) and run `weave check` again"
152                        .to_string(),
153                ),
154            }],
155            advisories: Vec::new(),
156            note: None,
157        }
158    }
159
160    /// OK is about FINDINGS only. Advisories never make a file not-OK — that is
161    /// the whole point of the register.
162    pub fn ok(&self) -> bool {
163        self.findings.is_empty()
164    }
165
166    /// The one line this file gets.
167    pub fn line(&self) -> String {
168        if let (true, Some(note)) = (self.ok(), &self.note) {
169            return format!("OK: {} — {note}", self.file);
170        }
171        if self.ok() {
172            return format!(
173                "OK: {} — markers cleared, no unanimous-line loss, no duplicated \
174                 definitions or lines, no dangling references",
175                self.file
176            );
177        }
178        let details: Vec<&str> = self.findings.iter().map(|f| f.detail.as_str()).collect();
179        format!("FOUND: {} — {}", self.file, details.join("; "))
180    }
181}
182
183/// Everything one run of the working-tree check looked at and concluded.
184///
185/// The *scope* travels with the verdicts on purpose: "no findings" and "no
186/// files" are different answers and a reader must never have to infer which
187/// one they got.
188#[derive(Debug, Clone)]
189pub struct Report {
190    /// What was compared, in words — `HEAD × MERGE_HEAD`, or why nothing was.
191    pub scope: String,
192    pub verdicts: Vec<Verdict>,
193}
194
195/// What `weave check` says when there is no merge to verify against.
196pub const NOTHING_TO_CHECK: &str = "no merge, rebase, cherry-pick or revert in progress and HEAD \
197     is not a merge commit, so there is no three-way context to verify a resolution against. \
198     NOTHING WAS CHECKED — this is not a clean bill of health.";
199
200/// Verify the resolution on disk in `dir` against the merge in progress (or
201/// HEAD's merge commit). `None`: no three-way context to verify against.
202pub fn check_in_progress(
203    dir: &std::path::Path,
204) -> Result<Option<Report>, Box<dyn std::error::Error>> {
205    let Some(scope) = crate::gitscan::merge_scope(dir)? else {
206        return Ok(None);
207    };
208    let mut verdicts = check(
209        &scope.base,
210        &scope.ours,
211        &scope.theirs,
212        &scope.work,
213        &scope.subjects,
214    );
215    verdicts.extend(
216        scope
217            .unreadable
218            .iter()
219            .map(|(file, why)| Verdict::unread(file, why)),
220    );
221    verdicts.extend(scope.irregular.iter().map(|file| {
222        Verdict::noted(
223            file,
224            "a symlink or submodule in a merge stage — not source, so git's guarantees stand",
225        )
226    }));
227    verdicts.sort_by(|a, b| a.file.cmp(&b.file));
228    Ok(Some(Report {
229        scope: match oversize_note(&scope.work) {
230            Some(note) => format!("{}; {note}", scope.scope),
231            None => scope.scope,
232        },
233        verdicts,
234    }))
235}
236
237impl Report {
238    /// The report as `weave check --json` prints it.
239    pub fn to_json(&self) -> serde_json::Value {
240        serde_json::json!({
241            "scope": self.scope,
242            "files": self.verdicts.iter().map(|v| serde_json::json!({
243                "file": v.file,
244                "ok": v.ok(),
245                "verdict": v.line(),
246                "findings": v.findings.iter().map(|f| serde_json::json!({
247                    "class": f.class,
248                    "detail": f.detail,
249                    "suggestion": f.suggestion,
250                })).collect::<Vec<_>>(),
251                // Advisories are non-blocking: they ride beside the verdict and
252                // never move `ok` or the exit code.
253                "advisories": v.advisories.iter().map(|a| serde_json::json!({
254                    "class": a.class,
255                    "entity": a.entity,
256                    "entity_type": a.entity_type,
257                    "detail": a.detail,
258                })).collect::<Vec<_>>(),
259            })).collect::<Vec<_>>(),
260        })
261    }
262
263    /// (files verified clean, files with findings, findings). One pass, one
264    /// answer: the summary sentence and the exit code are two readings of the
265    /// same tally, and computing them separately is how they drift.
266    pub fn tally(&self) -> (usize, usize, usize) {
267        let mut clean = 0;
268        let mut dirty = 0;
269        let mut findings = 0;
270        for v in &self.verdicts {
271            if v.ok() {
272                clean += 1;
273            } else {
274                dirty += 1;
275            }
276            findings += v.findings.len();
277        }
278        (clean, dirty, findings)
279    }
280
281    /// The whole report as the text a reader gets. Never empty, ever.
282    pub fn render(&self) -> String {
283        let mut out = format!("weave check: {}\n", self.scope);
284        if self.verdicts.is_empty() {
285            out.push_str(
286                "weave check: NOTHING WAS VERIFIED. This is not a clean bill of health — \
287                 weave had no file to look at.\n",
288            );
289            return out;
290        }
291        for v in &self.verdicts {
292            out.push_str(&v.line());
293            out.push('\n');
294            for f in &v.findings {
295                if let Some(s) = &f.suggestion {
296                    out.push_str(&format!("    suggestion ({}): {}\n", f.class, s));
297                }
298            }
299            // Advisories print UNDER the verdict, tagged `review`, whether or not
300            // the file is OK. They are not problems — they carry no exit code and
301            // no "FOUND" — so they read as a glance, not a task.
302            for a in &v.advisories {
303                out.push_str(&match a.class {
304                    "COOCCUPANCY" => format!(
305                        "    review (COOCCUPANCY): cleanly merged {} `{}` — {}; confirm they are meant to coexist\n",
306                        a.entity_type, a.entity, a.detail
307                    ),
308                    class => format!("    review ({class}): {}\n", a.detail),
309                });
310            }
311        }
312        let (good, bad, findings) = self.tally();
313        if bad == 0 {
314            out.push_str(&format!(
315                "weave check: {good} file(s) verified against the three merge stages — \
316                 every line both sides kept is present, nothing is stated more often than \
317                 either side stated it, every reference still resolves. Re-reading these \
318                 files end to end will not find anything this did not.\n"
319            ));
320        } else {
321            out.push_str(&format!(
322                "weave check: {good} file(s) clean, {bad} file(s) with findings \
323                 ({findings} total). Fix the files above; the clean ones need no further \
324                 reading.\n"
325            ));
326        }
327        out
328    }
329}
330
331/// The four checks, over one file.
332///
333/// `work` is the bytes on disk. `base` / `ours` / `theirs` are the three merge
334/// stages — the only reference frame in which "loss" and "duplicate" mean
335/// anything, because both are statements about what the two developers agreed.
336fn verify_file(
337    file: &str,
338    base: Option<&str>,
339    ours: Option<&str>,
340    theirs: Option<&str>,
341    work: &str,
342) -> Vec<Finding> {
343    let mut out = Vec::new();
344
345    // ---- 1. Markers -------------------------------------------------------
346    let marker_lines = weave_core::frame::marker_line_count(work);
347    if marker_lines > 0 {
348        out.push(Finding {
349            class: "MARKERS",
350            detail: format!("{marker_lines} conflict marker line(s) still present"),
351            suggestion: Some(
352                "the file is not resolved — every opening, separator and closing marker \
353                 line must go, along with the refused_by: comment inside each box"
354                    .to_string(),
355            ),
356        });
357    }
358    if work.lines().any(weave_core::conflict::is_teach_line) {
359        out.push(Finding {
360            class: "MARKERS",
361            detail: "weave's teach line is still in the file".to_string(),
362            suggestion: Some(
363                "delete the trailing `weave: run 'weave explain …'` comment; it is marker \
364                 furniture, not source"
365                    .to_string(),
366            ),
367        });
368    }
369
370    // ---- 2. Unanimous loss ------------------------------------------------
371    //
372    // A line both sides kept is a line neither developer asked to remove. That
373    // is the one class where "the resolver chose differently" is not a defence.
374    //
375    // The bound is base-relative and additive, because deletions from the two
376    // sides are: of base's `n` copies, ours kept `min(n, ours)` and theirs kept
377    // `min(n, theirs)`, and nothing says those are the same copies, so the
378    // survivors are the sum minus `n`. `min(ours, theirs)` — what this used to
379    // ask — is a condition the *developer's own resolution* fails wherever the
380    // two sides deleted different copies of a repeated line.
381    if let (Some(b), Some(o), Some(t)) = (base, ours, theirs) {
382        let (cb, co, ct, cw) = (counts(b), counts(o), counts(t), counts(work));
383        let mut lost: Vec<(&str, usize)> = cb
384            .iter()
385            .filter_map(|(line, n)| {
386                let required = unanimity_floor(
387                    *n,
388                    co.get(line).copied().unwrap_or(0),
389                    ct.get(line).copied().unwrap_or(0),
390                );
391                let found = cw.get(line).copied().unwrap_or(0);
392                // `then`, not `then_some`: the argument of `then_some` is
393                // evaluated whether or not the condition holds, and
394                // `required - found` underflows on every line that is NOT lost
395                // — which is almost all of them.
396                (found < required).then(|| (*line, required - found))
397            })
398            .collect();
399        lost.sort();
400        if !lost.is_empty() {
401            let total: usize = lost.iter().map(|(_, n)| n).sum();
402            out.push(Finding {
403                class: "LOSS",
404                detail: format!(
405                    "{total} line(s) BOTH sides kept are missing, e.g. `{}`",
406                    clip(lost[0].0)
407                ),
408                suggestion: Some(format!(
409                    "restore the {} distinct line(s) neither side deleted; nothing in this \
410                     merge licenses dropping them",
411                    lost.len()
412                )),
413            });
414        }
415    }
416
417    // ---- 3. Duplication ---------------------------------------------------
418    //
419    // Same ruler as the frame check: a line may appear as often as the side
420    // that says it most says it, and no oftener. This is checked over the
421    // whole file and not only over the frame because git folds one side's
422    // rename into "clean" context and the declaration lands twice, with no
423    // marker anywhere near it.
424    {
425        let cw = counts(work);
426        let (cb, co, ct) = (
427            counts(base.unwrap_or("")),
428            counts(ours.unwrap_or("")),
429            counts(theirs.unwrap_or("")),
430        );
431        // The ceiling `frame_duplicates` already uses. Additions from the two
432        // sides are additive exactly as deletions are, so a resolution that
433        // keeps both sides' new copy of a line is inside its budget — and at
434        // least one copy is always allowed, because a line the resolver wrote
435        // itself is allowed to exist. Twice is the question.
436        let allowance = |line: &str| {
437            duplication_ceiling(
438                cb.get(line).copied().unwrap_or(0),
439                co.get(line).copied().unwrap_or(0),
440                ct.get(line).copied().unwrap_or(0),
441                true,
442            )
443        };
444        let over: BTreeSet<&str> = cw
445            .iter()
446            .filter(|(line, found)| **found > allowance(line))
447            .map(|(line, _)| *line)
448            .collect();
449        // A fragment is only evidence in company: a doubled edit lands as a
450        // RUN of over-budget lines, whereas a call argument that repeats on its
451        // own is how the same call shape gets written twice. Adjacency is asked
452        // of the working tree, because that is where the run would be.
453        let mut in_a_run: BTreeSet<&str> = BTreeSet::new();
454        {
455            let lines: Vec<&str> = work.lines().map(str::trim).collect();
456            for pair in lines.windows(2) {
457                if over.contains(pair[0]) && over.contains(pair[1]) {
458                    in_a_run.insert(pair[0]);
459                    in_a_run.insert(pair[1]);
460                }
461            }
462        }
463        let mut dup: Vec<(&str, usize, usize)> = over
464            .iter()
465            .filter(|line| carries_identity(line) || in_a_run.contains(*line))
466            .map(|line| (*line, cw[line], allowance(line)))
467            .collect();
468        dup.sort();
469        if !dup.is_empty() {
470            out.push(Finding {
471                class: "DUP",
472                detail: format!(
473                    "{} line(s) appear more often than any version states them, e.g. `{}` \
474                     ({}x, at most {}x in base/ours/theirs)",
475                    dup.len(),
476                    clip(dup[0].0),
477                    dup[0].1,
478                    dup[0].2
479                ),
480                suggestion: Some(
481                    "delete the extra copies — a line stated twice usually means one side's \
482                     edit landed both inside and outside a marker"
483                        .to_string(),
484                ),
485            });
486        }
487    }
488
489    // A data file must still load, and state no key at one table path more
490    // often than either side does — the merge's own rule
491    // ([`weave_core::verify::structured_data`]).
492    if let Some(u) = weave_core::verify::structured_data(ours, theirs, work, file) {
493        let loads = weave_core::datafile::keys(file, work).is_some_and(|r| r.is_ok());
494        out.push(Finding {
495            class: if loads { "DUP" } else { "PARSE" },
496            detail: u.detail,
497            suggestion: Some(if loads {
498                "keep one entry for the key, with the settings both sides need".to_string()
499            } else {
500                "make the file load again; a side's version of it does".to_string()
501            }),
502        });
503    }
504
505    // A key stated twice in one switch / match / literal where neither side
506    // states it twice: two branches for one `case`, two values for one map
507    // key — the merge's own rule ([`weave_core::verify::duplicate_keys`]).
508    if let (Some(o), Some(t)) = (ours, theirs) {
509        if let Some(u) =
510            weave_core::verify::duplicate_keys(file, o, t, work, &crate::parsers::REGISTRY)
511        {
512            out.push(Finding {
513                class: "DUP",
514                detail: u.detail,
515                suggestion: Some(
516                    "keep one element for the key, combining what both sides need; two \
517                     elements for one key do not compile in some languages and silently \
518                     drop one side's in others"
519                        .to_string(),
520                ),
521            });
522        }
523    }
524
525    // Duplicate DEFINITIONS are worth their own finding: two `def f` in one
526    // file is a language-level bug, not a stylistic repeat, and the second one
527    // silently wins. Which is why the *identity* has to be the language's and
528    // not the name string's — see [`definition_key`].
529    let mut seen: BTreeMap<DefinitionKey, (String, usize)> = BTreeMap::new();
530    for e in entities_of(file, work) {
531        let Some(key) = definition_key(file, &e) else {
532            continue;
533        };
534        let slot = seen.entry(key).or_insert_with(|| (e.name.clone(), 0));
535        slot.1 += 1;
536    }
537    let dup_defs: Vec<&(String, usize)> = seen.values().filter(|(_, n)| *n > 1).collect();
538    if !dup_defs.is_empty() {
539        out.push(Finding {
540            class: "DUP",
541            detail: format!(
542                "{} name(s) are defined more than once: {}",
543                dup_defs.len(),
544                dup_defs
545                    .iter()
546                    .take(4)
547                    .map(|(n, c)| format!("`{n}` ({c}x)"))
548                    .collect::<Vec<_>>()
549                    .join(", ")
550            ),
551            suggestion: Some(
552                "keep one definition of each name; the later one shadows the earlier and \
553                 the earlier one's edits are silently dead"
554                    .to_string(),
555            ),
556        });
557    }
558
559    out
560}
561fn clip(l: &str) -> String {
562    let t = l.trim();
563    if t.chars().count() > 72 {
564        t.chars().take(71).chain(['…']).collect()
565    } else {
566        t.to_string()
567    }
568}
569
570/// Verify a whole working tree against the three merge stages.
571///
572/// `work` holds the bytes on disk for every supported file — the dangling pass
573/// needs the *repo*, not the file, because a definition deleted in `a.py` with
574/// its caller in `b.py` is invisible to any per-file rule.
575///
576/// Every repo-wide question is asked ONCE and indexed, never per subject: each
577/// file is parsed at most once per version, and a vanished name is looked up in
578/// a file's identifier set before any line of it is scanned. A merge with two
579/// thousand changed files is otherwise "every name × every file × every line",
580/// which is a check that does not finish.
581pub fn check(
582    base: &Tree,
583    ours: &Tree,
584    theirs: &Tree,
585    work: &Tree,
586    subjects: &[String],
587) -> Vec<Verdict> {
588    // Names still defined anywhere on disk. Repo-wide on purpose: a name a
589    // subject still calls is bound — not dangling — the moment ANY file, touched
590    // or not, still defines it, so the suppression set cannot be scoped to the
591    // subjects the way the stage trees are. A file too large to parse is asked
592    // lexically instead, below. In code, every name a file defines counts, at
593    // any depth: a function that moved into a class (or that the parser sees
594    // at a different depth than in base) is still defined, and so is a member
595    // defined out of its class under a qualified name (`Sheet::add_row`). A
596    // data file's nested keys are not definitions a call can reach, so only
597    // its top-level names count, as before.
598    let defined_now: BTreeSet<String> = work
599        .iter()
600        .filter(|(p, _)| is_supported(p))
601        .flat_map(|(p, c)| -> Vec<String> {
602            if !is_code(p) {
603                return entities_of(p, c).into_iter().map(|e| e.name).collect();
604            }
605            defined_names_of(p, c)
606                .into_iter()
607                .flat_map(|name| {
608                    let member = name
609                        .rsplit_once("::")
610                        .map(|(_, member)| member.to_string())
611                        .filter(|m| !m.is_empty());
612                    std::iter::once(name).chain(member)
613                })
614                .collect()
615        })
616        .collect();
617
618    // The names a merge stage defined that nothing on disk defines any more.
619    // This depends on the stages and the working tree, NOT on which subject we
620    // are looking at, so it is computed ONCE. The stage trees are already
621    // scoped to the subjects (see [`crate::gitscan::merge_scope`]); an
622    // untouched file's stage equals its working-tree copy, so it contributes
623    // only names that are in `defined_now` and can never be `gone`.
624    // Code files only: a Markdown heading or a YAML key is an entity weave
625    // merges, not a name a program calls. Base's parse is kept: it is where a
626    // vanished name's successor is looked for.
627    let mut gone: BTreeSet<String> = BTreeSet::new();
628    let mut base_entities: BTreeMap<&str, Vec<SemanticEntity>> = BTreeMap::new();
629    for (i, stage) in [base, ours, theirs].into_iter().enumerate() {
630        for (p, c) in stage.iter().filter(|(p, _)| is_code(p)) {
631            let entities = entities_of(p, c);
632            for e in &entities {
633                if !defined_now.contains(&e.name) {
634                    gone.insert(e.name.clone());
635                }
636            }
637            if i == 0 && !entities.is_empty() {
638                base_entities.insert(p.as_str(), entities);
639            }
640        }
641    }
642    // An oversize file on disk still defines what it declares.
643    for (_, c) in work.iter().filter(|(p, c)| is_code(p) && oversize(c)) {
644        let ids = identifiers(c);
645        gone.retain(|n| !(may_mention(&ids, c, n) && has_declaration(c, n)));
646    }
647
648    let renames = renames(base, ours, theirs, subjects);
649    let relocated = relocations(base, ours, theirs, work, subjects);
650    let mut successors = Successors {
651        base: &base_entities,
652        work,
653        memo: BTreeMap::new(),
654    };
655
656    let moddel = ModDelContext {
657        base,
658        ours,
659        theirs,
660        work,
661        subjects,
662        defined_now: &defined_now,
663    };
664    let mut verdicts = Vec::new();
665    for file in subjects {
666        let Some(w) = work.get(file) else {
667            if let Some(to) = relocated
668                .iter()
669                .find_map(|(to, (from, _))| (from == file).then_some(to))
670            {
671                verdicts.push(Verdict::noted(
672                    file,
673                    &format!(
674                        "not in the working tree: git moved it to `{to}`, where the directory \
675                         it was added to was renamed"
676                    ),
677                ));
678                continue;
679            }
680            if let Some(md) = ModDel::of(file, base, ours, theirs) {
681                verdicts.push(moddel.deleted(file, &md));
682                continue;
683            }
684            verdicts.push(
685                match missing_file_finding(file, base, ours, theirs, work, &renames) {
686                    Some(finding) => Verdict {
687                        file: file.clone(),
688                        findings: vec![finding],
689                        advisories: Vec::new(),
690                        note: None,
691                    },
692                    None => Verdict::noted(
693                        file,
694                        "not in the working tree, as the merge asked (one side deleted or \
695                     renamed it and the other left it alone)",
696                    ),
697                },
698            );
699            continue;
700        };
701        let (b, o, t) = match relocated.get(file) {
702            Some((from, _)) => (
703                None,
704                ours.get(from).map(String::as_str),
705                theirs.get(from).map(String::as_str),
706            ),
707            None => stages(file, base, ours, theirs, &renames),
708        };
709        let mut findings = verify_file(file, b, o, t, w);
710        if is_code(file) {
711            findings.extend(dangling(w, &gone, &mut successors));
712            findings.extend(dangling_imports(file, w, [b, o, t]));
713        }
714        let mut advisories = advisories_for(file, b, o, t);
715        if let Some(md) = ModDel::of(file, base, ours, theirs) {
716            let (f, a) = moddel.kept(file, w, &md);
717            findings.extend(f);
718            advisories.extend(a);
719        }
720        verdicts.push(Verdict {
721            file: file.clone(),
722            findings,
723            advisories,
724            note: None,
725        });
726    }
727    verdicts
728}
729
730/// The sentence a scope owes its reader about files too large to parse, if
731/// any: they were checked line by line, not for structure.
732pub fn oversize_note(work: &Tree) -> Option<String> {
733    let n = work
734        .iter()
735        .filter(|(p, c)| is_supported(p) && oversize(c))
736        .count();
737    (n > 0).then(|| {
738        format!(
739            "{n} file(s) over {} bytes were read for names lexically, not parsed for structure",
740            weave_core::merge::STRUCTURE_LIMIT_BYTES
741        )
742    })
743}
744
745/// Renames in the merge: `new path -> (old path, the side that renamed)`.
746///
747/// A subject that base does not have, that exactly one side has, is a rename
748/// when base has a path that side no longer has and whose content that side's
749/// new file mostly still states. Git's merge carries the other side's edits to
750/// the new path, so the three stages of the new path are base's and the other
751/// side's copies at the OLD path, and the renaming side's copy at the new one.
752/// Read without the rename, the new path has no base and no other side, and
753/// every line the other side added looks like a duplicate.
754///
755/// Scored through an index of base's lines, so an added file is compared only
756/// with the base files it shares a line with — not with every base file.
757fn renames(
758    base: &Tree,
759    ours: &Tree,
760    theirs: &Tree,
761    subjects: &[String],
762) -> BTreeMap<String, (String, Side)> {
763    let mut out = BTreeMap::new();
764    let added: Vec<(&String, Side)> = subjects
765        .iter()
766        .filter(|f| !base.contains_key(*f))
767        .filter_map(|f| match (ours.get(f), theirs.get(f)) {
768            (Some(_), None) => Some((f, Side::Ours)),
769            (None, Some(_)) => Some((f, Side::Theirs)),
770            _ => None,
771        })
772        .collect();
773    if added.is_empty() {
774        return out;
775    }
776    // line -> [(base path, copies of it there)], and each base file's count of
777    // significant lines.
778    let mut index: HashMap<&str, Vec<(&str, usize)>> = HashMap::new();
779    let mut sizes: HashMap<&str, usize> = HashMap::new();
780    for (p, old) in base {
781        let mut copies: HashMap<&str, usize> = HashMap::new();
782        for l in old.lines().map(str::trim).filter(|l| significant(l)) {
783            *copies.entry(l).or_insert(0) += 1;
784        }
785        sizes.insert(p.as_str(), copies.values().sum());
786        for (l, n) in copies {
787            index.entry(l).or_default().push((p.as_str(), n));
788        }
789    }
790    for (file, side) in added {
791        let renamer = side.of(ours, theirs);
792        let lines_b: BTreeSet<&str> = renamer[file]
793            .lines()
794            .map(str::trim)
795            .filter(|l| significant(l))
796            .collect();
797        let mut kept: HashMap<&str, usize> = HashMap::new();
798        for l in &lines_b {
799            for (p, n) in index.get(l).into_iter().flatten() {
800                *kept.entry(p).or_insert(0) += n;
801            }
802        }
803        let best = kept
804            .into_iter()
805            .filter(|(p, _)| *p != file.as_str() && !renamer.contains_key(*p))
806            .map(|(p, k)| (k as f64 / sizes[p].max(lines_b.len()) as f64, p))
807            .filter(|(score, _)| *score >= 0.5)
808            .max_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.1.cmp(b.1)));
809        if let Some((_, old_path)) = best {
810            out.insert(file.clone(), (old_path.to_string(), side));
811        }
812    }
813    out
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817enum Side {
818    Ours,
819    Theirs,
820}
821
822impl Side {
823    fn of<'a>(self, ours: &'a Tree, theirs: &'a Tree) -> &'a Tree {
824        match self {
825            Side::Ours => ours,
826            Side::Theirs => theirs,
827        }
828    }
829}
830
831/// Files git relocated: `placed path -> (the path a side added it at, side)`.
832///
833/// When one side renames a directory and the other adds a file inside it, git
834/// writes the addition at the renamed location and reports a conflict
835/// (`file location`). No merge stage has the placed path; the side's stage is
836/// at the path it was added at, which is no longer on disk. Read without this,
837/// the placed file is checked against three absent stages and every line it
838/// states twice looks like a duplicate — while the added path looks like a
839/// file the merge lost. The pair is recognised by what git keeps: the same file
840/// name, and the side's content on disk.
841fn relocations(
842    base: &Tree,
843    ours: &Tree,
844    theirs: &Tree,
845    work: &Tree,
846    subjects: &[String],
847) -> BTreeMap<String, (String, Side)> {
848    let name = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
849    let mut added: HashMap<String, Vec<(&String, Side)>> = HashMap::new();
850    for q in subjects {
851        if base.contains_key(q) || work.contains_key(q) {
852            continue;
853        }
854        let side = match (ours.get(q), theirs.get(q)) {
855            (Some(_), None) => Side::Ours,
856            (None, Some(_)) => Side::Theirs,
857            _ => continue,
858        };
859        added.entry(name(q)).or_default().push((q, side));
860    }
861    let mut out = BTreeMap::new();
862    for p in subjects {
863        let Some(w) = work.get(p) else { continue };
864        if base.contains_key(p) || ours.contains_key(p) || theirs.contains_key(p) {
865            continue;
866        }
867        let same: Vec<&(&String, Side)> = added
868            .get(&name(p))
869            .into_iter()
870            .flatten()
871            .filter(|(q, side)| side.of(ours, theirs).get(*q) == Some(w))
872            .collect();
873        if let [(q, side)] = same.as_slice() {
874            out.insert(p.clone(), ((*q).clone(), *side));
875        }
876    }
877    out
878}
879
880/// The three merge stages of one subject, following a rename.
881fn stages<'a>(
882    file: &str,
883    base: &'a Tree,
884    ours: &'a Tree,
885    theirs: &'a Tree,
886    renames: &BTreeMap<String, (String, Side)>,
887) -> (Option<&'a str>, Option<&'a str>, Option<&'a str>) {
888    let at = |tree: &'a Tree, path: &str| tree.get(path).map(String::as_str);
889    match renames.get(file) {
890        Some((old, Side::Theirs)) => (at(base, old), at(ours, old), at(theirs, file)),
891        Some((old, Side::Ours)) => (at(base, old), at(ours, file), at(theirs, old)),
892        None => (at(base, file), at(ours, file), at(theirs, file)),
893    }
894}
895
896/// What to say about a subject the working tree no longer has, if anything.
897///
898/// A deletion one side made and the other side did not touch is what the
899/// merge was asked to do, and so is a path the merge renamed away. Only a
900/// deletion that throws away someone's edit, or a file that both sides kept,
901/// is a finding.
902fn missing_file_finding(
903    file: &str,
904    base: &Tree,
905    ours: &Tree,
906    theirs: &Tree,
907    work: &Tree,
908    renames: &BTreeMap<String, (String, Side)>,
909) -> Option<Finding> {
910    let renamed_away = renames
911        .iter()
912        .any(|(new, (old, _))| old == file && work.contains_key(new));
913    if renamed_away {
914        return None;
915    }
916    let (b, o, t) = (base.get(file), ours.get(file), theirs.get(file));
917    let detail = match (b, o, t) {
918        (Some(_), None, None) => return None,
919        (Some(b), None, Some(t)) | (Some(b), Some(t), None) if b == t => return None,
920        // A modify/delete is judged by `ModDelContext`, never here.
921        (Some(_), None, Some(_)) | (Some(_), Some(_), None) => return None,
922        (_, Some(_), Some(_)) => {
923            "the file is not in the working tree, but both sides kept it (deleted during the \
924             merge)"
925        }
926        (None, _, _) => "the file is not in the working tree, but a side added it",
927    };
928    Some(Finding {
929        class: "MISSING",
930        detail: detail.to_string(),
931        suggestion: None,
932    })
933}
934
935/// A modify/delete: base had the file, one side deleted it, and the other
936/// side changed it.
937struct ModDel<'a> {
938    base: &'a str,
939    /// The modifying side's version.
940    modified: &'a str,
941    /// The side that deleted it.
942    deleter: Side,
943}
944
945impl<'a> ModDel<'a> {
946    fn of(file: &str, base: &'a Tree, ours: &'a Tree, theirs: &'a Tree) -> Option<Self> {
947        let b = base.get(file)?;
948        let (modified, deleter) = match (ours.get(file), theirs.get(file)) {
949            (None, Some(t)) => (t, Side::Ours),
950            (Some(o), None) => (o, Side::Theirs),
951            _ => return None,
952        };
953        (modified != b).then_some(ModDel {
954            base: b,
955            modified,
956            deleter,
957        })
958    }
959
960    fn sides(&self) -> (&'static str, &'static str) {
961        match self.deleter {
962            Side::Ours => ("ours", "theirs"),
963            Side::Theirs => ("theirs", "ours"),
964        }
965    }
966}
967
968/// Where the modifier's edit stands relative to the deleted file's successor.
969enum Port {
970    /// No file the deleter wrote is recognisably the deleted one.
971    NoSuccessor,
972    /// The successor already carries the edit.
973    Present(String),
974    /// The edit re-applies to the successor as a clean three-way merge; the
975    /// successor as it would be with it.
976    Portable(String, String),
977    /// The edit does not re-apply cleanly.
978    Conflicts(String),
979}
980
981/// The modify/delete rule, derived from what the two resolutions can lose.
982///
983/// Deleting a file one side modified loses the modification, unless it did
984/// not say anything (it only re-laid-out the file) or it already lives on in
985/// the file the deleter moved the content to. Keeping it loses the deletion,
986/// which matters only if the deletion was a MOVE — the content now exists
987/// twice — or if the kept file leans on names the deleter removed. Each of
988/// those is checked; anything else is a choice between two intents, which
989/// weave reports as an advisory and does not decide.
990///
991/// * DELETE is a finding only if (a) a surviving file still calls a name
992///   defined only in the deleted file, or (b) the modifier's edit is neither
993///   layout-only nor present in a successor file.
994/// * KEEP is a finding only if (a) the successor duplicates a definition the
995///   kept file still makes; (b) — the kept file calling a name the deleter
996///   removed elsewhere — is the dangling pass's `DANGLING` finding, which
997///   runs on every file on disk.
998///
999/// The successor is found, not assumed: a file the deleter added or changed,
1000/// sharing the most significant lines with the deleted file's base, the only
1001/// one at the first similarity tier (½, ⅓, ⅕) that has any candidate at all.
1002/// "Present" and "portable" are decided by the merge itself — the modifier's
1003/// edit, base → modified, merged onto the successor as found on disk.
1004///
1005/// This is where a move plus an edit (D5) is settled, and the only place it can
1006/// be: git hands the merge driver one path at a time, and a modify/delete is
1007/// never given to the driver at all, so no driver sees both the deleted file
1008/// and the file it moved to. `weave check` sees both trees. It OFFERS the port
1009/// — a `git apply` patch in the finding — and does not write it: the check
1010/// reads the working tree and never changes it.
1011struct ModDelContext<'a> {
1012    base: &'a Tree,
1013    ours: &'a Tree,
1014    theirs: &'a Tree,
1015    work: &'a Tree,
1016    subjects: &'a [String],
1017    defined_now: &'a BTreeSet<String>,
1018}
1019
1020impl ModDelContext<'_> {
1021    fn deleted(&self, file: &str, md: &ModDel) -> Verdict {
1022        let (deleter, modifier) = md.sides();
1023        let mut findings = Vec::new();
1024        let mut advisories = Vec::new();
1025        let advise = |detail: String| Advisory {
1026            class: "MODDEL",
1027            entity: file.to_string(),
1028            entity_type: "file".to_string(),
1029            detail,
1030        };
1031
1032        // (a) a name only the deleted file defined, still called on disk.
1033        if is_code(file) {
1034            let mut names: BTreeSet<String> = BTreeSet::new();
1035            for text in [md.base, md.modified] {
1036                names.extend(
1037                    entities_of(file, text)
1038                        .into_iter()
1039                        .map(|e| e.name)
1040                        .filter(|n| n.len() >= 3 && !self.defined_now.contains(n)),
1041                );
1042            }
1043            if let Some((name, caller)) = self.called_somewhere(&names) {
1044                findings.push(Finding {
1045                    class: "MODDEL",
1046                    detail: format!(
1047                        "{deleter} deleted this file, but `{caller}` still calls `{name}`, which \
1048                         only this file defined (modify/delete)"
1049                    ),
1050                    suggestion: Some(format!(
1051                        "keep the version {modifier} modified, or remove the calls to `{name}`"
1052                    )),
1053                });
1054            }
1055        }
1056
1057        // (b) the modifier's edit: layout, carried, portable, or lost.
1058        if weave_core::layout::layout_equal(md.base, md.modified, file) {
1059            advisories.push(advise(format!(
1060                "deleted as {deleter} asked; the change {modifier} made to it was layout only \
1061                 (whitespace), so nothing it said is lost"
1062            )));
1063        } else {
1064            match self.port(file, md) {
1065                Port::Present(to) => advisories.push(advise(format!(
1066                    "deleted as {deleter} asked; {deleter} moved its content to `{to}`, which \
1067                     already carries the edit {modifier} made"
1068                ))),
1069                Port::Portable(to, ported) => findings.push(Finding {
1070                    class: "MODDEL",
1071                    detail: format!(
1072                        "{deleter} moved this file to `{to}` and {modifier} edited it here; the \
1073                         edit is not in `{to}` (modify/delete)"
1074                    ),
1075                    suggestion: Some(format!(
1076                        "the edit re-applies to `{to}` cleanly — apply this patch (`git apply`):\n{}",
1077                        patch(&to, &self.work[&to], &ported)
1078                    )),
1079                }),
1080                Port::Conflicts(to) => findings.push(Finding {
1081                    class: "MODDEL",
1082                    detail: format!(
1083                        "{deleter} moved this file to `{to}` and {modifier} edited it here; the \
1084                         edit is not in `{to}` and does not re-apply there cleanly (modify/delete)"
1085                    ),
1086                    suggestion: Some(format!(
1087                        "carry the edit {modifier} made here into `{to}` by hand, or restore \
1088                         this file"
1089                    )),
1090                }),
1091                Port::NoSuccessor => findings.push(Finding {
1092                    class: "MODDEL",
1093                    detail: format!(
1094                        "the file is not in the working tree: {deleter} deleted it but \
1095                         {modifier} modified it, and the edit exists nowhere else \
1096                         (modify/delete)"
1097                    ),
1098                    suggestion: Some(format!(
1099                        "restore the version {modifier} modified if its edit should survive"
1100                    )),
1101                }),
1102            }
1103        }
1104        Verdict {
1105            file: file.to_string(),
1106            findings,
1107            advisories,
1108            note: None,
1109        }
1110    }
1111
1112    fn kept(&self, file: &str, w: &str, md: &ModDel) -> (Vec<Finding>, Vec<Advisory>) {
1113        let (deleter, _) = md.sides();
1114        if let Some(to) = self.successor(file, md) {
1115            let here: BTreeSet<String> = entities_of(file, w)
1116                .into_iter()
1117                .filter(|e| definition_key(file, e).is_some())
1118                .map(|e| e.name)
1119                .collect();
1120            let there: BTreeSet<String> = entities_of(&to, &self.work[&to])
1121                .into_iter()
1122                .filter(|e| definition_key(&to, e).is_some())
1123                .map(|e| e.name)
1124                .collect();
1125            let twice: Vec<String> = here.intersection(&there).take(4).cloned().collect();
1126            if !twice.is_empty() {
1127                let listed = twice
1128                    .iter()
1129                    .map(|n| format!("`{n}`"))
1130                    .collect::<Vec<_>>()
1131                    .join(", ");
1132                return (
1133                    vec![Finding {
1134                        class: "MODDEL",
1135                        detail: format!(
1136                            "{deleter} moved this file to `{to}`, and keeping it here defines \
1137                             {listed} in both (modify/delete)"
1138                        ),
1139                        suggestion: Some(format!(
1140                            "delete this file and carry the edit into `{to}` instead"
1141                        )),
1142                    }],
1143                    Vec::new(),
1144                );
1145            }
1146        }
1147        (
1148            Vec::new(),
1149            vec![Advisory {
1150                class: "MODDEL",
1151                entity: file.to_string(),
1152                entity_type: "file".to_string(),
1153                detail: format!(
1154                    "kept although {deleter} deleted it — whether the file should exist is the \
1155                     authors' call; nothing it defines is duplicated elsewhere"
1156                ),
1157            }],
1158        )
1159    }
1160
1161    /// The first vanished name some file on disk still calls, and that file.
1162    fn called_somewhere(&self, names: &BTreeSet<String>) -> Option<(String, String)> {
1163        if names.is_empty() {
1164            return None;
1165        }
1166        for (path, c) in self.work.iter().filter(|(p, _)| is_code(p)) {
1167            let ids = identifiers(c);
1168            for n in names {
1169                if may_mention(&ids, c, n) && !has_declaration(c, n) && has_call_reference(c, n) {
1170                    return Some((n.clone(), path.clone()));
1171                }
1172            }
1173        }
1174        None
1175    }
1176
1177    /// The file the deleter moved this one's content to, if exactly one
1178    /// candidate leads. See the type's docs.
1179    fn successor(&self, file: &str, md: &ModDel) -> Option<String> {
1180        let deleter_tree = md.deleter.of(self.ours, self.theirs);
1181        let mut want: HashMap<&str, usize> = HashMap::new();
1182        for l in md.base.lines().map(str::trim).filter(|l| significant(l)) {
1183            *want.entry(l).or_insert(0) += 1;
1184        }
1185        let size = want.values().sum::<usize>();
1186        if size == 0 {
1187            return None;
1188        }
1189        let scored: Vec<(f64, &String)> = self
1190            .subjects
1191            .iter()
1192            .filter(|p| p.as_str() != file && self.work.contains_key(*p))
1193            .filter(|p| match (deleter_tree.get(*p), self.base.get(*p)) {
1194                (Some(d), Some(b)) => d != b,
1195                (Some(_), None) => true,
1196                (None, _) => false,
1197            })
1198            .map(|p| {
1199                let mut have: HashMap<&str, usize> = HashMap::new();
1200                for l in self.work[p]
1201                    .lines()
1202                    .map(str::trim)
1203                    .filter(|l| significant(l))
1204                {
1205                    *have.entry(l).or_insert(0) += 1;
1206                }
1207                let shared: usize = want
1208                    .iter()
1209                    .map(|(l, n)| (*n).min(have.get(l).copied().unwrap_or(0)))
1210                    .sum();
1211                let theirs = have.values().sum::<usize>();
1212                (shared as f64 / size.max(theirs) as f64, p)
1213            })
1214            .collect();
1215        for tier in [0.5, 1.0 / 3.0, 0.2] {
1216            let at: Vec<&String> = scored
1217                .iter()
1218                .filter(|(s, _)| *s >= tier)
1219                .map(|(_, p)| *p)
1220                .collect();
1221            match at.len() {
1222                0 => continue,
1223                1 => return Some(at[0].clone()),
1224                _ => return None,
1225            }
1226        }
1227        None
1228    }
1229
1230    fn port(&self, file: &str, md: &ModDel) -> Port {
1231        let Some(to) = self.successor(file, md) else {
1232            return Port::NoSuccessor;
1233        };
1234        let now = &self.work[&to];
1235        let r = weave_core::entity_merge(md.base, now, md.modified, &to);
1236        if !r.is_clean() {
1237            return Port::Conflicts(to);
1238        }
1239        if weave_core::layout::layout_equal(&r.content, now, &to) {
1240            Port::Present(to)
1241        } else {
1242            Port::Portable(to, r.content)
1243        }
1244    }
1245}
1246
1247/// `old` → `new` at `path`, as a patch `git apply` takes.
1248fn patch(path: &str, old: &str, new: &str) -> String {
1249    let body = diffy::create_patch(old, new).to_string();
1250    let hunks = body.find("@@").map(|i| &body[i..]).unwrap_or("");
1251    format!("--- a/{path}\n+++ b/{path}\n{hunks}")
1252}
1253
1254/// Names a stage of THIS file bound — imported, or declared at file scope —
1255/// that the working tree still uses and no longer binds.
1256///
1257/// The repo-wide pass above cannot see these: the definition the name points
1258/// at still exists in its own file, and the break is that this file stopped
1259/// importing it. A merge produces it when each side deletes a different import
1260/// and one side's surviving code uses the name the other side's deletion
1261/// unbound.
1262///
1263/// With all three stages, this is the merge's own rule
1264/// ([`weave_core::verify::dangling_use`]), so the driver and `weave check`
1265/// cannot disagree about it. Without a base (an add/add), only imports are
1266/// read.
1267fn dangling_imports(file: &str, w: &str, stages: [Option<&str>; 3]) -> Vec<Finding> {
1268    if let [Some(b), Some(o), Some(t)] = stages {
1269        let parsed = [b, o, t, w].map(|text| entities_of(file, text));
1270        return weave_core::verify::dangling_use(
1271            file,
1272            [b, o, t, w],
1273            [&parsed[0], &parsed[1], &parsed[2], &parsed[3]],
1274        )
1275        .map(|n| {
1276            let import = [b, o, t]
1277                .iter()
1278                .flat_map(|s| s.lines())
1279                .find(|l| weave_core::verify::import_names(file, l).contains(&n));
1280            Finding {
1281                class: "DANGLING",
1282                detail: format!(
1283                    "`{n}` is still used here but no longer declared or imported; a merge \
1284                         stage bound it"
1285                ),
1286                suggestion: Some(match import {
1287                    Some(line) => format!(
1288                        "restore the import a merge stage had for `{n}`: `{}`",
1289                        line.trim()
1290                    ),
1291                    None => format!(
1292                        "restore the declaration of `{n}` a merge stage had, or remove its \
1293                             uses"
1294                    ),
1295                }),
1296            }
1297        })
1298        .into_iter()
1299        .collect();
1300    }
1301    let mut hits: BTreeMap<String, String> = BTreeMap::new();
1302    for stage in stages.into_iter().flatten() {
1303        for line in stage.lines() {
1304            for name in import_bindings(line) {
1305                if hits.contains_key(&name)
1306                    || has_binding(w, &name)
1307                    || !has_value_reference(w, &name)
1308                {
1309                    continue;
1310                }
1311                hits.insert(name, line.trim().to_string());
1312            }
1313        }
1314    }
1315    if hits.is_empty() {
1316        return Vec::new();
1317    }
1318    let listed = hits
1319        .keys()
1320        .take(4)
1321        .map(|n| format!("`{n}`"))
1322        .collect::<Vec<_>>()
1323        .join(", ");
1324    let (first, line) = hits.iter().next().expect("non-empty");
1325    vec![Finding {
1326        class: "DANGLING",
1327        detail: format!(
1328            "{} name(s) are still used here but no longer imported: {listed}",
1329            hits.len()
1330        ),
1331        suggestion: Some(format!(
1332            "restore the import a merge stage had for `{first}`: `{line}`"
1333        )),
1334    }]
1335}
1336
1337/// The co-occupancy advisories weave's own merge of the three stages would
1338/// raise: containers both sides changed different siblings of, which merged
1339/// clean. Read off the three stage texts, not off the bytes on disk — the fact
1340/// is about what the two authors did, and holds however the resolution was
1341/// written. Empty when any stage lacks the file (an add or a delete is not a
1342/// co-change) or nothing on disk parses it.
1343fn advisories_for(
1344    file: &str,
1345    base: Option<&str>,
1346    ours: Option<&str>,
1347    theirs: Option<&str>,
1348) -> Vec<Advisory> {
1349    let (Some(b), Some(o), Some(t)) = (base, ours, theirs) else {
1350        return Vec::new();
1351    };
1352    weave_core::entity_merge(b, o, t, file)
1353        .warnings
1354        .iter()
1355        .filter_map(|w| match &w.kind {
1356            weave_core::validate::WarningKind::SiblingCoChange {
1357                ours_added,
1358                ours_changed,
1359                theirs_added,
1360                theirs_changed,
1361            } => Some(Advisory {
1362                class: "COOCCUPANCY",
1363                entity: w.entity_name.clone(),
1364                entity_type: w.entity_type.clone(),
1365                detail: format!(
1366                    "both sides changed siblings ({}; {})",
1367                    weave_core::validate::co_change_side_phrase("ours", ours_added, ours_changed),
1368                    weave_core::validate::co_change_side_phrase(
1369                        "theirs",
1370                        theirs_added,
1371                        theirs_changed
1372                    ),
1373                ),
1374            }),
1375            _ => None,
1376        })
1377        .collect()
1378}
1379
1380/// The subset of the repo-wide vanished-name set (`gone`) that THIS file still
1381/// calls without defining — the file's dangling references.
1382///
1383/// `gone` is computed once by the caller and shared across every subject,
1384/// because it is a fact about the merge, not about the file being verified. Only
1385/// the per-file half lives here: which of those vanished names this file's bytes
1386/// actually call — asked of the file's identifier set first, so a name the file
1387/// never mentions costs a hash lookup, not a scan.
1388///
1389/// The rename repair is the derivable half: when the vanished name has a
1390/// same-file successor that IS defined now and did not exist in base, the fix
1391/// is a rename and weave can say which one.
1392fn dangling(w: &str, gone: &BTreeSet<String>, successors: &mut Successors) -> Vec<Finding> {
1393    let ids = identifiers(w);
1394    let mut hits: Vec<(String, Option<(String, String)>)> = Vec::new();
1395    for name in gone {
1396        if name.len() < 3
1397            || !may_mention(&ids, w, name)
1398            || has_declaration(w, name)
1399            || !has_call_reference(w, name)
1400        {
1401            continue;
1402        }
1403        hits.push((name.clone(), successors.of(name)));
1404    }
1405    if hits.is_empty() {
1406        return Vec::new();
1407    }
1408    let listed = hits
1409        .iter()
1410        .take(4)
1411        .map(|(n, _)| format!("`{n}`"))
1412        .collect::<Vec<_>>()
1413        .join(", ");
1414    let suggestion = hits.iter().find_map(|(n, s)| {
1415        s.as_ref().map(|(to, where_)| {
1416            format!(
1417                "`{n}` left `{where_}` and `{to}` arrived in its place — if that is the \
1418                 rename, rewrite every call of `{n}` here to `{to}`"
1419            )
1420        })
1421    });
1422    vec![Finding {
1423        class: "DANGLING",
1424        detail: format!(
1425            "{} name(s) are still referenced here but defined nowhere in the working tree: {}",
1426            hits.len(),
1427            listed
1428        ),
1429        suggestion: suggestion
1430            .or_else(|| Some("restore the definition, or remove the call sites".to_string())),
1431    }]
1432}
1433
1434/// The name a vanished definition was RENAMED to, and the file that says so —
1435/// answered once per name, however many subjects still call it.
1436///
1437/// This is the derivable half of a `DANGLING` finding, and it is derived by
1438/// the one matcher both weave passes already use — `sem-core`'s
1439/// `match_entities`, so the answer here and the answer the merge gave cannot
1440/// disagree. It is looked up in the file where the definition *lived*, not in
1441/// the file where the call survives: a new name next to a broken call is a
1442/// coincidence, a matched pair in the defining file is evidence.
1443struct Successors<'a> {
1444    /// Base's parse of every code subject, made once by [`check`].
1445    base: &'a BTreeMap<&'a str, Vec<SemanticEntity>>,
1446    work: &'a Tree,
1447    memo: BTreeMap<String, Option<(String, String)>>,
1448}
1449
1450impl Successors<'_> {
1451    fn of(&mut self, name: &str) -> Option<(String, String)> {
1452        if let Some(known) = self.memo.get(name) {
1453            return known.clone();
1454        }
1455        let found = self.find(name);
1456        self.memo.insert(name.to_string(), found.clone());
1457        found
1458    }
1459
1460    fn find(&self, name: &str) -> Option<(String, String)> {
1461        for (path, old) in self.base {
1462            if !old.iter().any(|e| e.name == name) {
1463                continue;
1464            }
1465            let Some(now) = self.work.get(*path) else {
1466                continue;
1467            };
1468            let new = entities_of(path, now);
1469            for c in match_entities(old, &new, path, None, None, None).changes {
1470                if c.change_type == ChangeType::Renamed
1471                    && c.old_entity_name.as_deref() == Some(name)
1472                    && c.entity_name != name
1473                    && !c.entity_name.is_empty()
1474                {
1475                    return Some((c.entity_name, path.to_string()));
1476                }
1477            }
1478            // The matcher pairs on body similarity, so a declaration that was
1479            // renamed AND rewritten comes back unpaired. One name out, one name
1480            // in, in the file that defined it, is then the only candidate there
1481            // is — and the suggestion says "if that is the rename" rather than
1482            // claiming it, because this is arithmetic and not evidence.
1483            let before: BTreeSet<&str> = old.iter().map(|e| e.name.as_str()).collect();
1484            let after: BTreeSet<&str> = new.iter().map(|e| e.name.as_str()).collect();
1485            let gone: Vec<&&str> = before.difference(&after).collect();
1486            let arrived: Vec<&&str> = after.difference(&before).collect();
1487            if gone.len() == 1 && arrived.len() == 1 && *gone[0] == name {
1488                return Some((arrived[0].to_string(), path.to_string()));
1489            }
1490        }
1491        None
1492    }
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497    use super::*;
1498
1499    fn tree(pairs: &[(&str, &str)]) -> Tree {
1500        pairs
1501            .iter()
1502            .map(|(p, c)| (p.to_string(), c.to_string()))
1503            .collect()
1504    }
1505
1506    const BASE: &str = "def a():\n    total_count = 0\n    return total_count\n\ndef keep():\n    return 'a stable helper line'\n";
1507
1508    /// Runs `f` on a watchdog thread: a regression fails the test instead of
1509    /// hanging the suite.
1510    fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
1511        let (tx, rx) = std::sync::mpsc::channel();
1512        std::thread::spawn(move || {
1513            let _ = tx.send(f());
1514        });
1515        rx.recv_timeout(std::time::Duration::from_secs(secs))
1516            .unwrap_or_else(|_| panic!("did not finish within {secs}s"))
1517    }
1518
1519    /// A data file with a hundred thousand same-named elements, each with a
1520    /// child: the shape `sem-core`'s id disambiguation is quadratic in. At
1521    /// this size an unbounded parse spins for many minutes; the check must not
1522    /// parse it at all, and must still verify everything else.
1523    #[test]
1524    fn a_huge_data_file_elsewhere_in_the_repo_does_not_stall_the_check() {
1525        let mut xml = String::from("<records>\n");
1526        let mut i = 0;
1527        while xml.len() <= 3 * weave_core::merge::STRUCTURE_LIMIT_BYTES {
1528            xml.push_str(&format!("  <record><id>{i}</id></record>\n"));
1529            i += 1;
1530        }
1531        xml.push_str("</records>\n");
1532        let ours = BASE.replace("total_count = 0", "total_count = 1");
1533        let theirs = BASE.replace("return 'a stable", "return 'one stable");
1534        let merged = ours.replace("return 'a stable", "return 'one stable");
1535        let work = tree(&[("m.py", &merged), ("data/records.xml", &xml)]);
1536        let v = within(60, move || {
1537            check(
1538                &tree(&[("m.py", BASE)]),
1539                &tree(&[("m.py", &ours)]),
1540                &tree(&[("m.py", &theirs)]),
1541                &work,
1542                &["m.py".to_string()],
1543            )
1544        });
1545        assert!(v[0].ok(), "{:#?}", v[0]);
1546        let note = oversize_note(&tree(&[("data/records.xml", "")])).is_none();
1547        assert!(note, "a small file needs no note");
1548    }
1549
1550    /// A file too large to parse still defines what it declares: the name is
1551    /// asked of it lexically, so a caller of it is not reported dangling.
1552    #[test]
1553    fn a_name_declared_only_in_an_oversize_file_is_not_dangling() {
1554        let mut big = String::from("def load_all_rows():\n    return []\n");
1555        while big.len() <= weave_core::merge::STRUCTURE_LIMIT_BYTES {
1556            big.push_str("# padding that makes this file too large to parse for structure\n");
1557        }
1558        let base = tree(&[
1559            ("a.py", "def load_all_rows():\n    return []\n"),
1560            ("b.py", "def go():\n    return load_all_rows()\n"),
1561        ]);
1562        let ours = tree(&[
1563            ("a.py", "def unrelated():\n    return 1\n"),
1564            ("b.py", "def go():\n    return load_all_rows()\n"),
1565        ]);
1566        let mut work = ours.clone();
1567        let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
1568        assert!(
1569            v[0].findings.iter().any(|f| f.class == "DANGLING"),
1570            "without a definition anywhere it dangles: {:#?}",
1571            v[0]
1572        );
1573        work.insert("vendor/big.py".to_string(), big);
1574        let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
1575        assert!(v[0].ok(), "{:#?}", v[0]);
1576        assert!(oversize_note(&work).is_some_and(|n| n.starts_with("1 file(s)")));
1577    }
1578
1579    /// Every subject calls a name the merge renamed away. Each repo-wide fact
1580    /// — which names vanished, where each lived, what it became — is worked
1581    /// out once, not once per subject: asked per subject, this was a parse of
1582    /// every base file for every hit, and a two-thousand-file merge spun for
1583    /// minutes.
1584    #[test]
1585    fn the_dangling_pass_scales_with_the_merge_not_its_square() {
1586        let n = 1500;
1587        let file = |i: usize| format!("pkg/m{i:04}.py");
1588        // Files of realistic length: the per-subject cost was every vanished
1589        // name times every line of the file.
1590        let padding: String = (0..60)
1591            .map(|k| format!("# note {k}: an ordinary line of commentary in a module\n"))
1592            .collect();
1593        let body = |i: usize, name: &str| {
1594            format!(
1595                "def {name}(x):\n    return x + {i}\n\n\ndef use_{i}():\n    return helper_{}(1)\n{padding}",
1596                (i + 1) % n
1597            )
1598        };
1599        let base: Tree = (0..n)
1600            .map(|i| (file(i), body(i, &format!("helper_{i}"))))
1601            .collect();
1602        let ours: Tree = (0..n)
1603            .map(|i| (file(i), body(i, &format!("renamed_helper_{i}"))))
1604            .collect();
1605        let subjects: Vec<String> = (0..n).map(file).collect();
1606        let v = within(120, move || {
1607            let work = ours.clone();
1608            check(&base, &ours, &base, &work, &subjects)
1609        });
1610        assert_eq!(v.len(), n);
1611        assert!(
1612            v.iter()
1613                .all(|v| v.findings.iter().any(|f| f.class == "DANGLING")),
1614            "every caller of a renamed helper dangles"
1615        );
1616    }
1617
1618    #[test]
1619    fn a_data_key_both_sides_added_is_a_duplicate_and_a_broken_file_is_parse() {
1620        let base = "[deps]\nalpha = \"1\"\nbravo = \"1\"\n";
1621        let ours = "[deps]\nalpha = \"1\"\nkit = \"0.6\"\nbravo = \"1\"\n";
1622        let theirs = "[deps]\nalpha = \"1\"\nbravo = \"1\"\nkit = { version = \"0.6\" }\n";
1623        let both =
1624            "[deps]\nalpha = \"1\"\nkit = \"0.6\"\nbravo = \"1\"\nkit = { version = \"0.6\" }\n";
1625        let run = |work: &str| {
1626            check(
1627                &tree(&[("Cargo.toml", base)]),
1628                &tree(&[("Cargo.toml", ours)]),
1629                &tree(&[("Cargo.toml", theirs)]),
1630                &tree(&[("Cargo.toml", work)]),
1631                &["Cargo.toml".to_string()],
1632            )
1633        };
1634        // TOML refuses a key stated twice at load, so this is a PARSE finding.
1635        let v = run(both);
1636        assert_eq!(findings_of(&v, "Cargo.toml")[0].class, "PARSE", "{v:#?}");
1637        assert!(run(ours).iter().all(Verdict::ok));
1638
1639        let base = "{\"a\": 1,\n \"b\": 2}\n";
1640        let ours = "{\"a\": 1,\n \"t\": 5,\n \"b\": 2}\n";
1641        let theirs = "{\"a\": 1,\n \"b\": 2,\n \"t\": 9}\n";
1642        let both = "{\"a\": 1,\n \"t\": 5,\n \"b\": 2,\n \"t\": 9}\n";
1643        let v = check(
1644            &tree(&[("p.json", base)]),
1645            &tree(&[("p.json", ours)]),
1646            &tree(&[("p.json", theirs)]),
1647            &tree(&[("p.json", both)]),
1648            &["p.json".to_string()],
1649        );
1650        let f = findings_of(&v, "p.json");
1651        assert!(
1652            f.iter()
1653                .any(|f| f.class == "DUP" && f.detail.contains("`t`")),
1654            "{v:#?}"
1655        );
1656    }
1657
1658    #[test]
1659    fn a_faithful_resolution_verifies_clean() {
1660        let ours = BASE.replace("total_count = 0", "total_count = 1");
1661        let theirs = BASE.replace("total_count = 0", "total_count = 2");
1662        let resolved = BASE.replace("total_count = 0", "total_count = 3");
1663        let v = check(
1664            &tree(&[("m.py", BASE)]),
1665            &tree(&[("m.py", &ours)]),
1666            &tree(&[("m.py", &theirs)]),
1667            &tree(&[("m.py", &resolved)]),
1668            &["m.py".to_string()],
1669        );
1670        assert!(v[0].ok(), "{:#?}", v[0]);
1671        assert!(v[0].line().starts_with("OK: m.py"));
1672    }
1673
1674    #[test]
1675    fn a_deliberately_lossy_resolution_is_caught() {
1676        let ours = BASE.replace("total_count = 0", "total_count = 1");
1677        let theirs = BASE.replace("total_count = 0", "total_count = 2");
1678        // Drops `keep`, which NEITHER side touched — the unanimous case.
1679        let lossy = "def a():\n    total_count = 3\n    return total_count\n";
1680        let v = check(
1681            &tree(&[("m.py", BASE)]),
1682            &tree(&[("m.py", &ours)]),
1683            &tree(&[("m.py", &theirs)]),
1684            &tree(&[("m.py", lossy)]),
1685            &["m.py".to_string()],
1686        );
1687        assert!(!v[0].ok(), "{:#?}", v[0]);
1688        assert!(
1689            v[0].findings.iter().any(|f| f.class == "LOSS"),
1690            "{:#?}",
1691            v[0]
1692        );
1693    }
1694
1695    #[test]
1696    fn markers_left_behind_are_the_first_thing_reported() {
1697        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";
1698        let v = check(
1699            &tree(&[("m.py", BASE)]),
1700            &tree(&[("m.py", BASE)]),
1701            &tree(&[("m.py", BASE)]),
1702            &tree(&[("m.py", conflicted)]),
1703            &["m.py".to_string()],
1704        );
1705        assert_eq!(v[0].findings[0].class, "MARKERS", "{:#?}", v[0]);
1706    }
1707
1708    #[test]
1709    fn a_line_stated_more_often_than_either_side_states_it_is_a_duplicate() {
1710        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";
1711        let v = check(
1712            &tree(&[("m.py", BASE)]),
1713            &tree(&[("m.py", BASE)]),
1714            &tree(&[("m.py", BASE)]),
1715            &tree(&[("m.py", dup)]),
1716            &["m.py".to_string()],
1717        );
1718        assert!(
1719            v[0].findings.iter().any(|f| f.class == "DUP"),
1720            "{:#?}",
1721            v[0]
1722        );
1723    }
1724
1725    #[test]
1726    fn a_call_whose_definition_left_the_repo_is_dangling() {
1727        let base = tree(&[
1728            ("a.py", "def fetch_user(i):\n    return i\n"),
1729            ("b.py", "def go():\n    return fetch_user(1)\n"),
1730        ]);
1731        let ours = tree(&[
1732            ("a.py", "def get_user(i):\n    return i\n"),
1733            ("b.py", "def go():\n    return fetch_user(1)\n"),
1734        ]);
1735        let work = ours.clone();
1736        let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
1737        assert!(
1738            v[0].findings.iter().any(|f| f.class == "DANGLING"),
1739            "{:#?}",
1740            v[0]
1741        );
1742    }
1743
1744    #[test]
1745    fn a_clean_container_co_change_is_an_advisory_not_a_finding() {
1746        // Both sides change DIFFERENT methods of one class; the resolution on
1747        // disk keeps both. The file is OK — no findings — yet the co-occupancy
1748        // is surfaced as a non-blocking review advisory.
1749        let base =
1750            "class C:\n    def a(self):\n        return 1\n\n    def b(self):\n        return 2\n";
1751        let ours = "class C:\n    def a(self):\n        return 1 + 10\n\n    def b(self):\n        return 2\n";
1752        let theirs = "class C:\n    def a(self):\n        return 1\n\n    def b(self):\n        return 2 + 20\n";
1753        let work = "class C:\n    def a(self):\n        return 1 + 10\n\n    def b(self):\n        return 2 + 20\n";
1754        let v = check(
1755            &tree(&[("m.py", base)]),
1756            &tree(&[("m.py", ours)]),
1757            &tree(&[("m.py", theirs)]),
1758            &tree(&[("m.py", work)]),
1759            &["m.py".to_string()],
1760        );
1761        assert!(
1762            v[0].ok(),
1763            "an advisory must not make the file not-OK: {:#?}",
1764            v[0]
1765        );
1766        assert!(
1767            v[0].findings.is_empty(),
1768            "advisory is not a finding: {:#?}",
1769            v[0]
1770        );
1771        assert_eq!(
1772            v[0].advisories.len(),
1773            1,
1774            "the co-change must be advised: {:#?}",
1775            v[0]
1776        );
1777        assert_eq!(v[0].advisories[0].entity, "C");
1778        // The report renders it under `review`, and the tally/exit are untouched.
1779        let report = Report {
1780            scope: "test".to_string(),
1781            verdicts: v,
1782        };
1783        assert_eq!(report.tally().1, 0, "no files with findings");
1784        assert!(
1785            report.render().contains("review (COOCCUPANCY)"),
1786            "{}",
1787            report.render()
1788        );
1789    }
1790
1791    /// One side moves a definition out of top level — into a class, as a
1792    /// method or member — and a caller elsewhere still calls it by name.
1793    /// `(defining file, its base copy, its moved copy, caller file, caller)`.
1794    const MOVED_INTO_A_CLASS: [(&str, &str, &str, &str, &str); 4] = [
1795        (
1796            "sheet.cpp",
1797            "void add_row(int x) {\n  total += x;\n}\n",
1798            "class Sheet {\n public:\n  void add_row(int x) {\n    total += x;\n  }\n};\n",
1799            "fill.cpp",
1800            "void Sheet::fill() {\n  add_row(1);\n}\n",
1801        ),
1802        // Declared in the class, defined out of it: the definition's name is
1803        // qualified, and it still defines the member.
1804        (
1805            "sheet.cpp",
1806            "void add_row(int x) {\n  total += x;\n}\n",
1807            "class Sheet {\n public:\n  void add_row(int x);\n};\n\n\
1808             void Sheet::add_row(int x) {\n  total += x;\n}\n",
1809            "fill.cpp",
1810            "void Sheet::fill() {\n  add_row(1);\n}\n",
1811        ),
1812        (
1813            "rows.py",
1814            "def add_row(x):\n    return x + 1\n",
1815            "class Sheet:\n    def add_row(self, x):\n        return x + 1\n",
1816            "fill.py",
1817            "def fill():\n    return add_row(1)\n",
1818        ),
1819        (
1820            "sheet.ts",
1821            "export function addRow(x: number) {\n  return x + 1;\n}\n",
1822            "export class Sheet {\n  addRow(x: number) {\n    return x + 1;\n  }\n}\n",
1823            "fill.ts",
1824            "function fill() {\n  return addRow(1);\n}\n",
1825        ),
1826    ];
1827
1828    fn check_move(
1829        defs: &str,
1830        base_def: &str,
1831        work_def: &str,
1832        caller: &str,
1833        call: &str,
1834    ) -> Vec<Verdict> {
1835        let base = tree(&[(defs, base_def), (caller, call)]);
1836        let ours = tree(&[(defs, work_def), (caller, call)]);
1837        check(
1838            &base,
1839            &ours,
1840            &base,
1841            &ours,
1842            &[defs.to_string(), caller.to_string()],
1843        )
1844    }
1845
1846    /// A name that is still defined — only now nested in a class — is not
1847    /// deleted, and its callers are not dangling. Whether a definition is
1848    /// still there is asked of every name the file defines, not just its
1849    /// top-level ones.
1850    #[test]
1851    fn a_definition_moved_into_a_class_still_defines_its_name() {
1852        for (defs, base_def, work_def, caller, call) in MOVED_INTO_A_CLASS {
1853            let v = check_move(defs, base_def, work_def, caller, call);
1854            assert!(
1855                findings_of(&v, caller)
1856                    .iter()
1857                    .all(|f| f.class != "DANGLING"),
1858                "{defs} -> nested: {v:#?}"
1859            );
1860            // And back out again: nested in base, top level on disk.
1861            let v = check_move(defs, work_def, base_def, caller, call);
1862            assert!(
1863                findings_of(&v, caller)
1864                    .iter()
1865                    .all(|f| f.class != "DANGLING"),
1866                "{defs} -> top level: {v:#?}"
1867            );
1868        }
1869    }
1870
1871    /// The negative control: the same definitions deleted outright — the
1872    /// class kept, the member gone — still leave their callers dangling.
1873    #[test]
1874    fn a_definition_deleted_outright_still_dangles() {
1875        let emptied = [
1876            (
1877                "sheet.cpp",
1878                "class Sheet {\n public:\n  void other_row(int x) {\n    total -= x;\n  }\n};\n",
1879                "add_row",
1880            ),
1881            (
1882                "rows.py",
1883                "class Sheet:\n    def other_row(self, x):\n        return x - 1\n",
1884                "add_row",
1885            ),
1886            (
1887                "sheet.ts",
1888                "export class Sheet {\n  otherRow(x: number) {\n    return x - 1;\n  }\n}\n",
1889                "addRow",
1890            ),
1891        ];
1892        for (defs, base_def, _, caller, call) in MOVED_INTO_A_CLASS {
1893            let (_, work_def, name) = emptied.iter().find(|(d, ..)| *d == defs).unwrap();
1894            let v = check_move(defs, base_def, work_def, caller, call);
1895            let dangling = findings_of(&v, caller)
1896                .into_iter()
1897                .find(|f| f.class == "DANGLING")
1898                .unwrap_or_else(|| panic!("{defs}: a deleted `{name}` must dangle: {v:#?}"));
1899            assert!(
1900                dangling.detail.contains(&format!("`{name}`")),
1901                "{dangling:#?}"
1902            );
1903        }
1904    }
1905
1906    /// A data file's nested key is not a definition: one named like a deleted
1907    /// function does not stand in for it.
1908    #[test]
1909    fn a_nested_data_key_does_not_define_a_deleted_name() {
1910        let config = "{\n  \"columns\": {\n    \"add_row\": true\n  }\n}\n";
1911        let (defs, base_def, _, caller, call) = MOVED_INTO_A_CLASS[2];
1912        let gone = "class Sheet:\n    def other_row(self, x):\n        return x - 1\n";
1913        let base = tree(&[(defs, base_def), (caller, call), ("config.json", config)]);
1914        let ours = tree(&[(defs, gone), (caller, call), ("config.json", config)]);
1915        let v = check(
1916            &base,
1917            &ours,
1918            &base,
1919            &ours,
1920            &[defs.to_string(), caller.to_string()],
1921        );
1922        assert!(
1923            findings_of(&v, caller)
1924                .iter()
1925                .any(|f| f.class == "DANGLING"),
1926            "{v:#?}"
1927        );
1928    }
1929
1930    fn findings_of<'a>(v: &'a [Verdict], file: &str) -> Vec<&'a Finding> {
1931        v.iter()
1932            .filter(|v| v.file == file)
1933            .flat_map(|v| v.findings.iter())
1934            .collect()
1935    }
1936
1937    const FLOW_BASE: &str = "\
1938import * as Alpha from \"./widget\"
1939import * as Beta from \"./gadget\"
1940import * as Widget from \"./widget\"
1941
1942export function* flow() {
1943  yield* Alpha.first()
1944  const spacer = 1
1945  yield* Widget.second()
1946}
1947";
1948
1949    /// Each side deleted a different import; the resolution on disk kept
1950    /// neither, and still uses one of the two names.
1951    #[test]
1952    fn a_type_whose_import_the_merge_dropped_is_dangling() {
1953        // Theirs deleted the import as unused; ours started using the type,
1954        // as a type argument only. The resolution kept both edits.
1955        let base = "package p;\n\nimport a.model.Receipt;\nimport java.util.List;\n\npublic class S {\n    public List<String> all() {\n        return null;\n    }\n}\n";
1956        let ours = base.replace("List<String>", "List<Receipt>");
1957        let theirs = base.replace("import a.model.Receipt;\n", "");
1958        let work = ours.replace("import a.model.Receipt;\n", "");
1959        let f = "S.java";
1960        let v = check(
1961            &tree(&[(f, base)]),
1962            &tree(&[(f, &ours)]),
1963            &tree(&[(f, &theirs)]),
1964            &tree(&[(f, &work)]),
1965            &[f.to_string()],
1966        );
1967        assert!(
1968            v[0].findings.iter().any(|f| f.class == "DANGLING"),
1969            "{:#?}",
1970            v[0]
1971        );
1972        // Keeping the import is the resolution.
1973        let v = check(
1974            &tree(&[(f, base)]),
1975            &tree(&[(f, &ours)]),
1976            &tree(&[(f, &theirs)]),
1977            &tree(&[(f, &ours)]),
1978            &[f.to_string()],
1979        );
1980        assert!(
1981            !v[0].findings.iter().any(|f| f.class == "DANGLING"),
1982            "{:#?}",
1983            v[0]
1984        );
1985    }
1986
1987    #[test]
1988    fn a_namespace_whose_import_the_merge_dropped_is_dangling() {
1989        let ours = FLOW_BASE
1990            .replace("import * as Widget from \"./widget\"\n", "")
1991            .replace("Widget.second()", "Beta.second()");
1992        let theirs = FLOW_BASE
1993            .replace("import * as Alpha from \"./widget\"\n", "")
1994            .replace("Alpha.first()", "Widget.first()");
1995        let work = FLOW_BASE
1996            .replace("import * as Alpha from \"./widget\"\n", "")
1997            .replace("import * as Widget from \"./widget\"\n", "")
1998            .replace("Alpha.first()", "Widget.first()")
1999            .replace("Widget.second()", "Beta.second()");
2000        let v = check(
2001            &tree(&[("flow.ts", FLOW_BASE)]),
2002            &tree(&[("flow.ts", &ours)]),
2003            &tree(&[("flow.ts", &theirs)]),
2004            &tree(&[("flow.ts", &work)]),
2005            &["flow.ts".to_string()],
2006        );
2007        let dangling: Vec<_> = findings_of(&v, "flow.ts")
2008            .into_iter()
2009            .filter(|f| f.class == "DANGLING")
2010            .collect();
2011        assert_eq!(dangling.len(), 1, "{v:#?}");
2012        assert!(dangling[0].detail.contains("`Widget`"), "{v:#?}");
2013        assert!(!dangling[0].detail.contains("`Alpha`"), "{v:#?}");
2014        assert!(
2015            dangling[0]
2016                .suggestion
2017                .as_deref()
2018                .is_some_and(|s| s.contains("import * as Widget from \"./widget\"")),
2019            "{v:#?}"
2020        );
2021        // A faithful resolution that keeps the import is clean.
2022        let fixed = format!("import * as Widget from \"./widget\"\n{work}");
2023        let v = check(
2024            &tree(&[("flow.ts", FLOW_BASE)]),
2025            &tree(&[("flow.ts", &ours)]),
2026            &tree(&[("flow.ts", &theirs)]),
2027            &tree(&[("flow.ts", &fixed)]),
2028            &["flow.ts".to_string()],
2029        );
2030        assert!(v[0].ok(), "{v:#?}");
2031    }
2032
2033    #[test]
2034    fn a_file_one_side_deleted_and_the_other_left_alone_is_not_a_finding() {
2035        let gone = "export function unused() {\n  return 1\n}\n";
2036        let v = check(
2037            &tree(&[("gone.ts", gone)]),
2038            &tree(&[("gone.ts", gone)]),
2039            &tree(&[]),
2040            &tree(&[]),
2041            &["gone.ts".to_string()],
2042        );
2043        assert!(findings_of(&v, "gone.ts").is_empty(), "{v:#?}");
2044        assert!(
2045            v[0].line()
2046                .starts_with("OK: gone.ts — not in the working tree"),
2047            "a deletion as asked still gets its line: {v:#?}"
2048        );
2049        // …but a deletion against a modification still is.
2050        let edited = gone.replace("return 1", "return 2");
2051        let v = check(
2052            &tree(&[("gone.ts", gone)]),
2053            &tree(&[("gone.ts", &edited)]),
2054            &tree(&[]),
2055            &tree(&[]),
2056            &["gone.ts".to_string()],
2057        );
2058        let f = findings_of(&v, "gone.ts");
2059        assert_eq!(f.len(), 1, "{v:#?}");
2060        assert!(f[0].detail.contains("modified"), "{v:#?}");
2061    }
2062
2063    // ---- MODDEL -----------------------------------------------------------
2064    //
2065    // One file, `f.py`; one side deletes it, the other edits it. A caller
2066    // `main.py` that does or does not use it; for a move, the deleter's `g.py`.
2067
2068    const F: &str =
2069        "def helper(x):\n    return x + 1\n\n\ndef compute(y):\n    return helper(y) * 2\n";
2070    const MAIN_NOUSE: &str = "print(3)\n";
2071    const MAIN_USES: &str = "from f import compute\n\nprint(compute(3))\n";
2072    /// Moved and restructured, with a docstring right above the line the
2073    /// other side edits: diff3 cannot place both.
2074    const G_TOUCHING: &str = "# moved and restructured\n\n\ndef helper(x):\n    '''Increment.'''\n    return x + 1\n\n\ndef compute(y):\n    '''Double of helper.'''\n    value = helper(y)\n    return value * 2\n";
2075    /// Moved and restructured, leaving `helper`'s body as it was.
2076    const G_MOVED: &str = "# moved and restructured\n\n\ndef helper(x):\n    return x + 1\n\n\ndef compute(y):\n    '''Double of helper.'''\n    value = helper(y)\n    return value * 2\n";
2077
2078    fn f_edit() -> String {
2079        F.replace("return x + 1", "return x + 2  # fixed off-by-one")
2080    }
2081
2082    /// ours deletes `f.py` (and may write `ours_extra`); theirs edits it. The
2083    /// resolution on disk is `work`.
2084    fn moddel_check(
2085        main: &str,
2086        theirs_f: &str,
2087        ours_extra: &[(&str, &str)],
2088        work: &[(&str, &str)],
2089    ) -> Vec<Verdict> {
2090        let base = tree(&[("f.py", F), ("main.py", main)]);
2091        let mut o = vec![("main.py", main)];
2092        o.extend_from_slice(ours_extra);
2093        let ours = tree(&o);
2094        let theirs = tree(&[("f.py", theirs_f), ("main.py", main)]);
2095        let mut subjects = vec!["f.py".to_string()];
2096        subjects.extend(ours_extra.iter().map(|(p, _)| p.to_string()));
2097        check(&base, &ours, &theirs, &tree(work), &subjects)
2098    }
2099
2100    fn verdict<'a>(v: &'a [Verdict], file: &str) -> &'a Verdict {
2101        v.iter()
2102            .find(|x| x.file == file)
2103            .expect("a verdict for every subject")
2104    }
2105
2106    #[test]
2107    fn moddel_delete_that_loses_an_edit_is_a_finding() {
2108        let edit = f_edit();
2109        let v = moddel_check(MAIN_NOUSE, &edit, &[], &[("main.py", MAIN_NOUSE)]);
2110        let f = &verdict(&v, "f.py").findings;
2111        assert_eq!(f.len(), 1, "{v:#?}");
2112        assert_eq!(f[0].class, "MODDEL");
2113        assert!(f[0].detail.contains("exists nowhere else"), "{v:#?}");
2114    }
2115
2116    #[test]
2117    fn moddel_delete_of_a_layout_only_edit_is_an_advisory() {
2118        let reflowed = F
2119            .replace("return x + 1", "return  x+1")
2120            .replace("\n\n\n", "\n\n\n\n");
2121        let v = moddel_check(MAIN_NOUSE, &reflowed, &[], &[("main.py", MAIN_NOUSE)]);
2122        let f = verdict(&v, "f.py");
2123        assert!(f.ok(), "{v:#?}");
2124        assert_eq!(f.advisories.len(), 1);
2125        assert_eq!(f.advisories[0].class, "MODDEL");
2126        // A comment is content: the same change with a comment is not layout.
2127        let commented = F.replace("def helper(x):", "def helper(x):  # adds one");
2128        let v = moddel_check(MAIN_NOUSE, &commented, &[], &[("main.py", MAIN_NOUSE)]);
2129        assert!(!verdict(&v, "f.py").ok(), "{v:#?}");
2130    }
2131
2132    #[test]
2133    fn moddel_delete_whose_names_are_still_called_is_a_finding() {
2134        let reflowed = F.replace("return x + 1", "return  x+1");
2135        let v = moddel_check(MAIN_USES, &reflowed, &[], &[("main.py", MAIN_USES)]);
2136        let f = &verdict(&v, "f.py").findings;
2137        assert_eq!(f.len(), 1, "{v:#?}");
2138        assert_eq!(f[0].class, "MODDEL");
2139        assert!(f[0].detail.contains("`compute`"), "{v:#?}");
2140    }
2141
2142    #[test]
2143    fn moddel_delete_after_a_move_that_carries_the_edit_is_an_advisory() {
2144        let edit = f_edit();
2145        let ported = G_MOVED.replace("return x + 1", "return x + 2  # fixed off-by-one");
2146        let v = moddel_check(
2147            MAIN_NOUSE,
2148            &edit,
2149            &[("g.py", G_MOVED)],
2150            &[("main.py", MAIN_NOUSE), ("g.py", &ported)],
2151        );
2152        let f = verdict(&v, "f.py");
2153        assert!(f.ok(), "{v:#?}");
2154        assert!(f.advisories[0].detail.contains("`g.py`"), "{v:#?}");
2155    }
2156
2157    #[test]
2158    fn moddel_delete_after_a_move_that_dropped_the_edit_offers_the_port() {
2159        let edit = f_edit();
2160        let v = moddel_check(
2161            MAIN_NOUSE,
2162            &edit,
2163            &[("g.py", G_MOVED)],
2164            &[("main.py", MAIN_NOUSE), ("g.py", G_MOVED)],
2165        );
2166        let f = &verdict(&v, "f.py").findings;
2167        assert_eq!(f.len(), 1, "{v:#?}");
2168        assert_eq!(f[0].class, "MODDEL");
2169        let s = f[0].suggestion.as_deref().unwrap();
2170        assert!(s.contains("--- a/g.py"), "{s}");
2171        assert!(s.contains("+    return x + 2  # fixed off-by-one"), "{s}");
2172    }
2173
2174    #[test]
2175    fn moddel_delete_after_a_move_the_edit_cannot_follow_is_a_finding() {
2176        let edit = f_edit();
2177        let v = moddel_check(
2178            MAIN_NOUSE,
2179            &edit,
2180            &[("g.py", G_TOUCHING)],
2181            &[("main.py", MAIN_NOUSE), ("g.py", G_TOUCHING)],
2182        );
2183        let f = &verdict(&v, "f.py").findings;
2184        assert_eq!(f.len(), 1, "{v:#?}");
2185        assert!(f[0].detail.contains("does not re-apply"), "{v:#?}");
2186    }
2187
2188    #[test]
2189    fn moddel_keep_after_a_move_duplicates_the_definitions() {
2190        let edit = f_edit();
2191        let v = moddel_check(
2192            MAIN_NOUSE,
2193            &edit,
2194            &[("g.py", G_MOVED)],
2195            &[("main.py", MAIN_NOUSE), ("g.py", G_MOVED), ("f.py", &edit)],
2196        );
2197        let f = &verdict(&v, "f.py").findings;
2198        assert_eq!(f.len(), 1, "{v:#?}");
2199        assert_eq!(f[0].class, "MODDEL");
2200        assert!(f[0].detail.contains("`compute`"), "{v:#?}");
2201    }
2202
2203    #[test]
2204    fn moddel_keep_without_a_move_is_an_advisory() {
2205        let edit = f_edit();
2206        let v = moddel_check(
2207            MAIN_NOUSE,
2208            &edit,
2209            &[],
2210            &[("main.py", MAIN_NOUSE), ("f.py", &edit)],
2211        );
2212        let f = verdict(&v, "f.py");
2213        assert!(f.ok(), "{v:#?}");
2214        assert_eq!(f.advisories.len(), 1);
2215        assert_eq!(f.advisories[0].class, "MODDEL");
2216        assert!(f.line().starts_with("OK: f.py"), "{v:#?}");
2217    }
2218
2219    #[test]
2220    fn a_file_git_relocated_into_a_renamed_directory_is_checked_against_its_adder() {
2221        // theirs added `old/job.py`; ours renamed `old/` to `new/`; git wrote
2222        // the addition at `new/job.py`. Lines it states twice are its own.
2223        let job =
2224            "@staticmethod\ndef a():\n    return 1\n\n@staticmethod\ndef b():\n    return 2\n";
2225        let v = check(
2226            &tree(&[]),
2227            &tree(&[]),
2228            &tree(&[("old/job.py", job)]),
2229            &tree(&[("new/job.py", job)]),
2230            &["new/job.py".to_string(), "old/job.py".to_string()],
2231        );
2232        assert!(v.iter().all(Verdict::ok), "{v:#?}");
2233        assert!(
2234            verdict(&v, "old/job.py").line().contains("`new/job.py`"),
2235            "{v:#?}"
2236        );
2237    }
2238
2239    #[test]
2240    fn a_local_const_arrow_resolves_the_name() {
2241        let base = tree(&[
2242            ("a.ts", "export function openThing() {\n  return 1\n}\n"),
2243            (
2244                "b.ts",
2245                "export function outer() {\n  const openThing = (n: number): number => {\n    return n\n  }\n  return openThing(1)\n}\n",
2246            ),
2247        ]);
2248        let ours = tree(&[
2249            ("a.ts", "export function other() {\n  return 2\n}\n"),
2250            (
2251                "b.ts",
2252                "export function outer() {\n  const openThing = (n: number): number => {\n    return n\n  }\n  return openThing(1)\n}\n",
2253            ),
2254        ]);
2255        let theirs = tree(&[
2256            ("a.ts", "export function openThing() {\n  return 1\n}\n"),
2257            (
2258                "b.ts",
2259                "export function outer() {\n  const openThing = (n: number): number => {\n    return n + 1\n  }\n  return openThing(1)\n}\n",
2260            ),
2261        ]);
2262        let work = tree(&[
2263            ("a.ts", "export function other() {\n  return 2\n}\n"),
2264            (
2265                "b.ts",
2266                "export function outer() {\n  const openThing = (n: number): number => {\n    return n + 1\n  }\n  return openThing(1)\n}\n",
2267            ),
2268        ]);
2269        let v = check(
2270            &base,
2271            &ours,
2272            &theirs,
2273            &work,
2274            &["a.ts".to_string(), "b.ts".to_string()],
2275        );
2276        assert!(findings_of(&v, "b.ts").is_empty(), "{v:#?}");
2277    }
2278
2279    #[test]
2280    fn prose_is_not_checked_for_references() {
2281        let base = tree(&[
2282            (
2283                "util.ts",
2284                "export function clamp(n: number) {\n  return n\n}\n",
2285            ),
2286            ("notes.md", "# Notes\n\nWe clamp (roughly) the total.\n"),
2287        ]);
2288        let ours = tree(&[
2289            (
2290                "util.ts",
2291                "export function floor(n: number) {\n  return n\n}\n",
2292            ),
2293            ("notes.md", "# Notes\n\nWe clamp (roughly) the total.\n"),
2294        ]);
2295        let theirs = tree(&[
2296            (
2297                "util.ts",
2298                "export function clamp(n: number) {\n  return n\n}\n",
2299            ),
2300            (
2301                "notes.md",
2302                "# Notes\n\nWe clamp (roughly) the total, always.\n",
2303            ),
2304        ]);
2305        let work = tree(&[
2306            (
2307                "util.ts",
2308                "export function floor(n: number) {\n  return n\n}\n",
2309            ),
2310            (
2311                "notes.md",
2312                "# Notes\n\nWe clamp (roughly) the total, always.\n",
2313            ),
2314        ]);
2315        let v = check(
2316            &base,
2317            &ours,
2318            &theirs,
2319            &work,
2320            &["notes.md".to_string(), "util.ts".to_string()],
2321        );
2322        assert!(findings_of(&v, "notes.md").is_empty(), "{v:#?}");
2323    }
2324
2325    #[test]
2326    fn line_counts_follow_a_rename() {
2327        let base_text =
2328            "export function* widgetFlow() {\n  yield* widget.open(gadget.name)\n  return 0\n}\n";
2329        let ours_text = "export function* widgetFlow() {\n  yield* widget.open(gadget.name)\n  yield* widget.open(gadget.name)\n  yield* widget.open(gadget.name)\n  return 0\n}\n";
2330        let v = check(
2331            &tree(&[("old.ts", base_text)]),
2332            &tree(&[("old.ts", ours_text)]),
2333            &tree(&[("new.ts", base_text)]),
2334            &tree(&[("new.ts", ours_text)]),
2335            &["new.ts".to_string(), "old.ts".to_string()],
2336        );
2337        assert!(findings_of(&v, "new.ts").is_empty(), "{v:#?}");
2338        assert!(findings_of(&v, "old.ts").is_empty(), "{v:#?}");
2339    }
2340
2341    #[test]
2342    fn a_report_with_no_files_says_so_in_a_sentence_and_never_prints_nothing() {
2343        let r = Report {
2344            scope: "no merge in progress".to_string(),
2345            verdicts: Vec::new(),
2346        };
2347        let text = r.render();
2348        assert!(text.contains("NOTHING WAS VERIFIED"), "{text}");
2349        assert!(!text.trim().is_empty());
2350        assert!(!text.trim().starts_with('['));
2351    }
2352}