use std::collections::{BTreeMap, BTreeSet, HashMap};
use sem_core::model::change::ChangeType;
use sem_core::model::entity::SemanticEntity;
use sem_core::model::identity::match_entities;
use crate::parsers::{entities_of, is_supported};
use crate::repo_scope::Tree;
use weave_core::binding::{has_call_reference, has_definition};
fn significant(line: &str) -> bool {
let t = line.trim();
t.len() >= 8 && t.chars().any(|c| c.is_alphanumeric())
}
fn is_fragment(line: &str) -> bool {
let t = line.trim();
let opens_or_continues = |s: &str| {
s.ends_with([
',', '(', '[', '{', ':', '+', '-', '*', '/', '&', '|', '=', '<', '>', '?',
]) || s.ends_with("=>")
|| s.ends_with("->")
};
let starts_as_continuation = t.starts_with(['.', ',', ')', ']', '}', '?', ':', '+'])
|| t.starts_with("&&")
|| t.starts_with("||");
opens_or_continues(t) || starts_as_continuation
}
fn carries_identity(line: &str) -> bool {
significant(line) && !is_fragment(line)
}
fn counts(text: &str) -> HashMap<&str, usize> {
let mut m: HashMap<&str, usize> = HashMap::new();
for l in text.lines().map(str::trim).filter(|l| significant(l)) {
*m.entry(l).or_insert(0) += 1;
}
m
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub class: &'static str,
pub detail: String,
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Advisory {
pub entity: String,
pub entity_type: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
pub file: String,
pub findings: Vec<Finding>,
pub advisories: Vec<Advisory>,
}
impl Verdict {
pub fn ok(&self) -> bool {
self.findings.is_empty()
}
pub fn line(&self) -> String {
if self.ok() {
return format!(
"OK: {} — markers cleared, no unanimous-line loss, no duplicated \
definitions or lines, no dangling references",
self.file
);
}
let details: Vec<&str> = self.findings.iter().map(|f| f.detail.as_str()).collect();
format!("FOUND: {} — {}", self.file, details.join("; "))
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub scope: String,
pub verdicts: Vec<Verdict>,
}
impl Report {
pub fn tally(&self) -> (usize, usize, usize) {
let mut clean = 0;
let mut dirty = 0;
let mut findings = 0;
for v in &self.verdicts {
if v.ok() {
clean += 1;
} else {
dirty += 1;
}
findings += v.findings.len();
}
(clean, dirty, findings)
}
pub fn render(&self) -> String {
let mut out = format!("weave check: {}\n", self.scope);
if self.verdicts.is_empty() {
out.push_str(
"weave check: NOTHING WAS VERIFIED. This is not a clean bill of health — \
weave had no file to look at.\n",
);
return out;
}
for v in &self.verdicts {
out.push_str(&v.line());
out.push('\n');
for f in &v.findings {
if let Some(s) = &f.suggestion {
out.push_str(&format!(" suggestion ({}): {}\n", f.class, s));
}
}
for a in &v.advisories {
out.push_str(&format!(
" review (COOCCUPANCY): cleanly merged {} `{}` — {}; confirm they are meant to coexist\n",
a.entity_type, a.entity, a.detail
));
}
}
let (good, bad, findings) = self.tally();
if bad == 0 {
out.push_str(&format!(
"weave check: {good} file(s) verified against the three merge stages — \
every line both sides kept is present, nothing is stated more often than \
either side stated it, every reference still resolves. Re-reading these \
files end to end will not find anything this did not.\n"
));
} else {
out.push_str(&format!(
"weave check: {good} file(s) clean, {bad} file(s) with findings \
({findings} total). Fix the files above; the clean ones need no further \
reading.\n"
));
}
out
}
}
fn verify_file(
file: &str,
base: Option<&str>,
ours: Option<&str>,
theirs: Option<&str>,
work: &str,
) -> Vec<Finding> {
let mut out = Vec::new();
let marker_lines = weave_core::frame::marker_line_count(work);
if marker_lines > 0 {
out.push(Finding {
class: "MARKERS",
detail: format!("{marker_lines} conflict marker line(s) still present"),
suggestion: Some(
"the file is not resolved — every opening, separator and closing marker \
line must go, along with the refused_by: comment inside each box"
.to_string(),
),
});
}
if work.lines().any(weave_core::conflict::is_teach_line) {
out.push(Finding {
class: "MARKERS",
detail: "weave's teach line is still in the file".to_string(),
suggestion: Some(
"delete the trailing `weave: run 'weave explain …'` comment; it is marker \
furniture, not source"
.to_string(),
),
});
}
if let (Some(b), Some(o), Some(t)) = (base, ours, theirs) {
let (cb, co, ct, cw) = (counts(b), counts(o), counts(t), counts(work));
let mut lost: Vec<(&str, usize)> = cb
.iter()
.filter_map(|(line, n)| {
let kept_o = co.get(line).copied().unwrap_or(0).min(*n);
let kept_t = ct.get(line).copied().unwrap_or(0).min(*n);
#[allow(clippy::implicit_saturating_sub)]
let required = if kept_o + kept_t > *n {
kept_o + kept_t - *n
} else {
0
};
let found = cw.get(line).copied().unwrap_or(0);
(found < required).then(|| (*line, required - found))
})
.collect();
lost.sort();
if !lost.is_empty() {
let total: usize = lost.iter().map(|(_, n)| n).sum();
out.push(Finding {
class: "LOSS",
detail: format!(
"{total} line(s) BOTH sides kept are missing, e.g. `{}`",
clip(lost[0].0)
),
suggestion: Some(format!(
"restore the {} distinct line(s) neither side deleted; nothing in this \
merge licenses dropping them",
lost.len()
)),
});
}
}
{
let cw = counts(work);
let (cb, co, ct) = (
counts(base.unwrap_or("")),
counts(ours.unwrap_or("")),
counts(theirs.unwrap_or("")),
);
let allowance = |line: &str| {
let (n, o, t) = (
cb.get(line).copied().unwrap_or(0),
co.get(line).copied().unwrap_or(0),
ct.get(line).copied().unwrap_or(0),
);
#[allow(clippy::implicit_saturating_sub)]
let additive = if o + t > n { o + t - n } else { 0 };
additive.max(o).max(t).max(1)
};
let over: BTreeSet<&str> = cw
.iter()
.filter(|(line, found)| **found > allowance(line))
.map(|(line, _)| *line)
.collect();
let mut in_a_run: BTreeSet<&str> = BTreeSet::new();
{
let lines: Vec<&str> = work.lines().map(str::trim).collect();
for pair in lines.windows(2) {
if over.contains(pair[0]) && over.contains(pair[1]) {
in_a_run.insert(pair[0]);
in_a_run.insert(pair[1]);
}
}
}
let mut dup: Vec<(&str, usize, usize)> = over
.iter()
.filter(|line| carries_identity(line) || in_a_run.contains(*line))
.map(|line| (*line, cw[line], allowance(line)))
.collect();
dup.sort();
if !dup.is_empty() {
out.push(Finding {
class: "DUP",
detail: format!(
"{} line(s) appear more often than any version states them, e.g. `{}` \
({}x, at most {}x in base/ours/theirs)",
dup.len(),
clip(dup[0].0),
dup[0].1,
dup[0].2
),
suggestion: Some(
"delete the extra copies — a line stated twice usually means one side's \
edit landed both inside and outside a marker"
.to_string(),
),
});
}
}
let mut seen: BTreeMap<DefinitionKey, (String, usize)> = BTreeMap::new();
for e in entities_of(file, work) {
let Some(key) = definition_key(file, &e) else {
continue;
};
let slot = seen.entry(key).or_insert_with(|| (e.name.clone(), 0));
slot.1 += 1;
}
let dup_defs: Vec<&(String, usize)> = seen.values().filter(|(_, n)| *n > 1).collect();
if !dup_defs.is_empty() {
out.push(Finding {
class: "DUP",
detail: format!(
"{} name(s) are defined more than once: {}",
dup_defs.len(),
dup_defs
.iter()
.take(4)
.map(|(n, c)| format!("`{n}` ({c}x)"))
.collect::<Vec<_>>()
.join(", ")
),
suggestion: Some(
"keep one definition of each name; the later one shadows the earlier and \
the earlier one's edits are silently dead"
.to_string(),
),
});
}
out
}
type DefinitionKey = (String, String, Option<String>);
fn declares_a_name(entity_type: &str) -> bool {
!matches!(
entity_type,
"impl"
| "extension"
| "instance"
| "module"
| "internal_module"
| "namespace"
| "interface"
| "test"
| "test_suite"
| "export"
| "import"
| "package"
| "use"
)
}
fn overloads(path: &str) -> bool {
matches!(
path.rsplit('.').next().unwrap_or(""),
"java"
| "kt"
| "kts"
| "cs"
| "cpp"
| "cc"
| "cxx"
| "hpp"
| "hh"
| "h"
| "c"
| "ts"
| "tsx"
| "swift"
| "scala"
| "php"
)
}
fn without_generics(name: &str) -> &str {
match name.find('<') {
Some(i) if i > 0 => name[..i].trim_end(),
_ => name,
}
}
fn parameter_list(content: &str) -> Option<String> {
let header: String = content.lines().take(3).collect::<Vec<_>>().join(" ");
let open = header.find('(')?;
let mut depth = 0usize;
for (i, c) in header[open..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(
header[open + 1..open + i]
.chars()
.filter(|c| !c.is_whitespace())
.collect(),
);
}
}
_ => {}
}
}
None
}
fn definition_key(path: &str, e: &SemanticEntity) -> Option<DefinitionKey> {
if e.name.is_empty() || !declares_a_name(&e.entity_type) {
return None;
}
let signature = overloads(path)
.then(|| parameter_list(&e.content))
.flatten();
Some((
e.entity_type.clone(),
without_generics(&e.name).to_string(),
signature,
))
}
fn clip(l: &str) -> String {
let t = l.trim();
if t.chars().count() > 72 {
t.chars().take(71).chain(['…']).collect()
} else {
t.to_string()
}
}
pub fn check(
base: &Tree,
ours: &Tree,
theirs: &Tree,
work: &Tree,
subjects: &[String],
) -> Vec<Verdict> {
let defined_now: BTreeSet<String> = work
.iter()
.filter(|(p, _)| is_supported(p))
.flat_map(|(p, c)| entities_of(p, c).into_iter().map(|e| e.name))
.collect();
let mut gone: BTreeSet<String> = BTreeSet::new();
for stage in [base, ours, theirs] {
for (p, c) in stage.iter().filter(|(p, _)| is_supported(p)) {
for e in entities_of(p, c) {
if !defined_now.contains(&e.name) {
gone.insert(e.name);
}
}
}
}
let mut verdicts = Vec::new();
for file in subjects {
let Some(w) = work.get(file) else {
verdicts.push(Verdict {
file: file.clone(),
findings: vec![Finding {
class: "MARKERS",
detail: "the file is not in the working tree (deleted during the merge)"
.to_string(),
suggestion: None,
}],
advisories: Vec::new(),
});
continue;
};
let mut findings = verify_file(
file,
base.get(file).map(String::as_str),
ours.get(file).map(String::as_str),
theirs.get(file).map(String::as_str),
w,
);
findings.extend(dangling(w, base, work, &gone));
let advisories = advisories_for(
file,
base.get(file).map(String::as_str),
ours.get(file).map(String::as_str),
theirs.get(file).map(String::as_str),
);
verdicts.push(Verdict {
file: file.clone(),
findings,
advisories,
});
}
verdicts
}
fn advisories_for(
file: &str,
base: Option<&str>,
ours: Option<&str>,
theirs: Option<&str>,
) -> Vec<Advisory> {
let (Some(b), Some(o), Some(t)) = (base, ours, theirs) else {
return Vec::new();
};
weave_core::entity_merge(b, o, t, file)
.warnings
.iter()
.filter_map(|w| match &w.kind {
weave_core::validate::WarningKind::SiblingCoChange {
ours_added,
ours_changed,
theirs_added,
theirs_changed,
} => Some(Advisory {
entity: w.entity_name.clone(),
entity_type: w.entity_type.clone(),
detail: format!(
"both sides changed siblings ({}; {})",
weave_core::validate::co_change_side_phrase("ours", ours_added, ours_changed),
weave_core::validate::co_change_side_phrase(
"theirs",
theirs_added,
theirs_changed
),
),
}),
_ => None,
})
.collect()
}
fn dangling(w: &str, base: &Tree, work: &Tree, gone: &BTreeSet<String>) -> Vec<Finding> {
let mut hits: Vec<(String, Option<(String, String)>)> = Vec::new();
for name in gone {
if name.len() < 3 || has_definition(w, name) || !has_call_reference(w, name) {
continue;
}
hits.push((name.clone(), successor_of(name, base, work)));
}
if hits.is_empty() {
return Vec::new();
}
let listed = hits
.iter()
.take(4)
.map(|(n, _)| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ");
let suggestion = hits.iter().find_map(|(n, s)| {
s.as_ref().map(|(to, where_)| {
format!(
"`{n}` left `{where_}` and `{to}` arrived in its place — if that is the \
rename, rewrite every call of `{n}` here to `{to}`"
)
})
});
vec![Finding {
class: "DANGLING",
detail: format!(
"{} name(s) are still referenced here but defined nowhere in the working tree: {}",
hits.len(),
listed
),
suggestion: suggestion
.or_else(|| Some("restore the definition, or remove the call sites".to_string())),
}]
}
fn successor_of(name: &str, base: &Tree, work: &Tree) -> Option<(String, String)> {
for (path, before) in base.iter().filter(|(p, _)| is_supported(p)) {
let old = entities_of(path, before);
if !old.iter().any(|e| e.name == name) {
continue;
}
let Some(now) = work.get(path) else { continue };
let new = entities_of(path, now);
for c in match_entities(&old, &new, path, None, None, None).changes {
if c.change_type == ChangeType::Renamed
&& c.old_entity_name.as_deref() == Some(name)
&& c.entity_name != name
&& !c.entity_name.is_empty()
{
return Some((c.entity_name, path.clone()));
}
}
let before: BTreeSet<&str> = old.iter().map(|e| e.name.as_str()).collect();
let after: BTreeSet<&str> = new.iter().map(|e| e.name.as_str()).collect();
let gone: Vec<&&str> = before.difference(&after).collect();
let arrived: Vec<&&str> = after.difference(&before).collect();
if gone.len() == 1 && arrived.len() == 1 && *gone[0] == name {
return Some((arrived[0].to_string(), path.clone()));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(pairs: &[(&str, &str)]) -> Tree {
pairs
.iter()
.map(|(p, c)| (p.to_string(), c.to_string()))
.collect()
}
const BASE: &str = "def a():\n total_count = 0\n return total_count\n\ndef keep():\n return 'a stable helper line'\n";
#[test]
fn a_faithful_resolution_verifies_clean() {
let ours = BASE.replace("total_count = 0", "total_count = 1");
let theirs = BASE.replace("total_count = 0", "total_count = 2");
let resolved = BASE.replace("total_count = 0", "total_count = 3");
let v = check(
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", &ours)]),
&tree(&[("m.py", &theirs)]),
&tree(&[("m.py", &resolved)]),
&["m.py".to_string()],
);
assert!(v[0].ok(), "{:#?}", v[0]);
assert!(v[0].line().starts_with("OK: m.py"));
}
#[test]
fn a_deliberately_lossy_resolution_is_caught() {
let ours = BASE.replace("total_count = 0", "total_count = 1");
let theirs = BASE.replace("total_count = 0", "total_count = 2");
let lossy = "def a():\n total_count = 3\n return total_count\n";
let v = check(
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", &ours)]),
&tree(&[("m.py", &theirs)]),
&tree(&[("m.py", lossy)]),
&["m.py".to_string()],
);
assert!(!v[0].ok(), "{:#?}", v[0]);
assert!(
v[0].findings.iter().any(|f| f.class == "LOSS"),
"{:#?}",
v[0]
);
}
#[test]
fn markers_left_behind_are_the_first_thing_reported() {
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";
let v = check(
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", conflicted)]),
&["m.py".to_string()],
);
assert_eq!(v[0].findings[0].class, "MARKERS", "{:#?}", v[0]);
}
#[test]
fn a_line_stated_more_often_than_either_side_states_it_is_a_duplicate() {
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";
let v = check(
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", BASE)]),
&tree(&[("m.py", dup)]),
&["m.py".to_string()],
);
assert!(
v[0].findings.iter().any(|f| f.class == "DUP"),
"{:#?}",
v[0]
);
}
#[test]
fn a_call_whose_definition_left_the_repo_is_dangling() {
let base = tree(&[
("a.py", "def fetch_user(i):\n return i\n"),
("b.py", "def go():\n return fetch_user(1)\n"),
]);
let ours = tree(&[
("a.py", "def get_user(i):\n return i\n"),
("b.py", "def go():\n return fetch_user(1)\n"),
]);
let work = ours.clone();
let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
assert!(
v[0].findings.iter().any(|f| f.class == "DANGLING"),
"{:#?}",
v[0]
);
}
#[test]
fn a_clean_container_co_change_is_an_advisory_not_a_finding() {
let base =
"class C:\n def a(self):\n return 1\n\n def b(self):\n return 2\n";
let ours = "class C:\n def a(self):\n return 1 + 10\n\n def b(self):\n return 2\n";
let theirs = "class C:\n def a(self):\n return 1\n\n def b(self):\n return 2 + 20\n";
let work = "class C:\n def a(self):\n return 1 + 10\n\n def b(self):\n return 2 + 20\n";
let v = check(
&tree(&[("m.py", base)]),
&tree(&[("m.py", ours)]),
&tree(&[("m.py", theirs)]),
&tree(&[("m.py", work)]),
&["m.py".to_string()],
);
assert!(
v[0].ok(),
"an advisory must not make the file not-OK: {:#?}",
v[0]
);
assert!(
v[0].findings.is_empty(),
"advisory is not a finding: {:#?}",
v[0]
);
assert_eq!(
v[0].advisories.len(),
1,
"the co-change must be advised: {:#?}",
v[0]
);
assert_eq!(v[0].advisories[0].entity, "C");
let report = Report {
scope: "test".to_string(),
verdicts: v,
};
assert_eq!(report.tally().1, 0, "no files with findings");
assert!(
report.render().contains("review (COOCCUPANCY)"),
"{}",
report.render()
);
}
#[test]
fn a_report_with_no_files_says_so_in_a_sentence_and_never_prints_nothing() {
let r = Report {
scope: "no merge in progress".to_string(),
verdicts: Vec::new(),
};
let text = r.render();
assert!(text.contains("NOTHING WAS VERIFIED"), "{text}");
assert!(!text.trim().is_empty());
assert!(!text.trim().starts_with('['));
}
}