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