use std::collections::{BTreeMap, BTreeSet, HashSet};
use sem_core::model::change::ChangeType;
use sem_core::model::entity::SemanticEntity;
use sem_core::model::identity::match_entities;
use crate::arity::{self, CallSite, Signature};
use crate::parsers::entities_of;
use crate::wire::{
Bindings, Finding, Findings, Reference, RelatedRef, Rename, SCHEMA_VERSION_1_1,
SCHEMA_VERSION_1_4,
};
use weave_core::binding::{has_call_reference, has_definition};
pub type Tree = BTreeMap<String, String>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Side {
Ours,
Theirs,
}
impl Side {
fn wire(self) -> &'static str {
match self {
Side::Ours => "ours",
Side::Theirs => "theirs",
}
}
fn other(self) -> Side {
match self {
Side::Ours => Side::Theirs,
Side::Theirs => Side::Ours,
}
}
}
#[derive(Debug, Clone)]
struct Def {
name: String,
entity_type: String,
signature: Option<Signature>,
}
#[derive(Default)]
struct FileVersions {
base: Option<String>,
ours: Option<String>,
theirs: Option<String>,
base_defs: Vec<Def>,
ours_defs: Vec<Def>,
theirs_defs: Vec<Def>,
ours_renames: BTreeMap<String, String>,
theirs_renames: BTreeMap<String, String>,
}
impl FileVersions {
fn version(&self, side: Side) -> Option<&String> {
match side {
Side::Ours => self.ours.as_ref(),
Side::Theirs => self.theirs.as_ref(),
}
}
fn defs(&self, side: Side) -> &[Def] {
match side {
Side::Ours => &self.ours_defs,
Side::Theirs => &self.theirs_defs,
}
}
fn renames(&self, side: Side) -> &BTreeMap<String, String> {
match side {
Side::Ours => &self.ours_renames,
Side::Theirs => &self.theirs_renames,
}
}
fn touched(&self, side: Side) -> bool {
self.version(side) != self.base.as_ref()
}
}
fn defs_of(entities: &[SemanticEntity], path: &str) -> Vec<Def> {
entities
.iter()
.map(|e| Def {
name: e.name.clone(),
entity_type: e.entity_type.clone(),
signature: (e.entity_type == "function")
.then(|| arity::params(path, &e.name, &e.content))
.flatten(),
})
.collect()
}
fn rename_map(
before: &[SemanticEntity],
after: &[SemanticEntity],
path: &str,
) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
for c in match_entities(before, after, path, None, None, None).changes {
if c.change_type == ChangeType::Renamed {
if let Some(old) = c.old_entity_name.clone() {
if old != c.entity_name && !old.is_empty() && !c.entity_name.is_empty() {
map.insert(old, c.entity_name.clone());
}
}
}
}
map
}
pub(crate) use crate::parsers::is_supported;
pub fn check(base: &Tree, ours: &Tree, theirs: &Tree) -> Vec<Findings> {
let mut paths: BTreeSet<&String> = BTreeSet::new();
paths.extend(base.keys());
paths.extend(ours.keys());
paths.extend(theirs.keys());
let mut files: BTreeMap<String, FileVersions> = BTreeMap::new();
for path in paths {
if !is_supported(path) {
continue;
}
let mut fv = FileVersions {
base: base.get(path).cloned(),
ours: ours.get(path).cloned(),
theirs: theirs.get(path).cloned(),
..Default::default()
};
let base_e = entities_of(path, fv.base.as_deref().unwrap_or(""));
let ours_e = entities_of(path, fv.ours.as_deref().unwrap_or(""));
let theirs_e = entities_of(path, fv.theirs.as_deref().unwrap_or(""));
fv.ours_renames = rename_map(&base_e, &ours_e, path);
fv.theirs_renames = rename_map(&base_e, &theirs_e, path);
fv.base_defs = defs_of(&base_e, path);
fv.ours_defs = defs_of(&ours_e, path);
fv.theirs_defs = defs_of(&theirs_e, path);
files.insert(path.clone(), fv);
}
let mut merged_defs: HashSet<String> = HashSet::new();
let mut base_defs_all: HashSet<String> = HashSet::new();
for fv in files.values() {
for d in &fv.base_defs {
base_defs_all.insert(d.name.clone());
}
let (o, t) = (fv.touched(Side::Ours), fv.touched(Side::Theirs));
let survivors: Vec<&Def> = match (o, t) {
(true, true) => fv.ours_defs.iter().chain(fv.theirs_defs.iter()).collect(),
(true, false) => fv.ours_defs.iter().collect(),
(false, true) => fv.theirs_defs.iter().collect(),
(false, false) => fv.base_defs.iter().collect(),
};
for d in survivors {
merged_defs.insert(d.name.clone());
}
}
let side_defs = |side: Side| -> HashSet<String> {
files
.values()
.filter(|fv| fv.version(side).is_some())
.flat_map(|fv| fv.defs(side))
.map(|def| def.name.clone())
.collect()
};
let ours_defs_all = side_defs(Side::Ours);
let theirs_defs_all = side_defs(Side::Theirs);
let mut out: FindingSink = FindingSink::default();
for actor in [Side::Ours, Side::Theirs] {
let other = actor.other();
for (path, fv) in &files {
if !fv.touched(actor) || fv.base.is_none() {
continue;
}
let actor_names: HashSet<&str> =
fv.defs(actor).iter().map(|d| d.name.as_str()).collect();
let renames = fv.renames(actor);
for d in &fv.base_defs {
if actor_names.contains(d.name.as_str()) {
continue;
}
if merged_defs.contains(&d.name) {
continue;
}
let renamed_to = renames.get(&d.name).cloned();
for (other_path, other_fv) in &files {
if other_path == path || !other_fv.touched(other) {
continue;
}
let Some(content) = other_fv.version(other) else {
continue;
};
if has_definition(content, &d.name) {
continue;
}
if !has_call_reference(content, &d.name) {
continue;
}
out.push(
other_path,
dangling_finding(
&d.name,
&d.entity_type,
actor,
path,
renamed_to.as_deref(),
),
renamed_to.as_ref().map(|to| Rename {
from: d.name.clone(),
to: to.clone(),
side: actor.wire().to_string(),
}),
Reference {
entity: d.name.clone(),
defined_in_output: false,
referenced_in_output: true,
},
);
}
}
}
}
for actor in [Side::Ours, Side::Theirs] {
let other = actor.other();
let other_defs_all = match other {
Side::Ours => &ours_defs_all,
Side::Theirs => &theirs_defs_all,
};
for (path, fv) in &files {
if !fv.touched(actor) {
continue;
}
let base_names: HashSet<&str> = fv.base_defs.iter().map(|d| d.name.as_str()).collect();
let rename_targets: HashSet<&str> =
fv.renames(actor).values().map(|s| s.as_str()).collect();
for d in fv.defs(actor) {
if base_names.contains(d.name.as_str()) || rename_targets.contains(d.name.as_str())
{
continue;
}
if base_defs_all.contains(&d.name) {
continue;
}
if other_defs_all.contains(&d.name) {
continue;
}
for (other_path, other_fv) in &files {
if other_path == path || !other_fv.touched(other) {
continue;
}
let Some(content) = other_fv.version(other) else {
continue;
};
if has_definition(content, &d.name) || !has_call_reference(content, &d.name) {
continue;
}
out.push(
other_path,
shadow_finding(&d.name, &d.entity_type, actor, path),
None,
Reference {
entity: d.name.clone(),
defined_in_output: false,
referenced_in_output: true,
},
);
}
}
}
}
let mut defined_in: BTreeMap<&str, usize> = BTreeMap::new();
for fv in files.values() {
let mut names: HashSet<&str> = HashSet::new();
for d in &fv.base_defs {
names.insert(d.name.as_str());
}
for n in names {
*defined_in.entry(n).or_default() += 1;
}
}
for actor in [Side::Ours, Side::Theirs] {
let other = actor.other();
for (path, fv) in &files {
if !fv.touched(actor) || fv.touched(other) || fv.base.is_none() {
continue;
}
for d in fv.defs(actor) {
let Some(new) = &d.signature else { continue };
if defined_in.get(d.name.as_str()) != Some(&1) {
continue;
}
let Some(old) = fv
.base_defs
.iter()
.find(|b| b.name == d.name)
.and_then(|b| b.signature.as_ref())
else {
continue;
};
if old.params == new.params {
continue;
}
for (other_path, other_fv) in &files {
if other_path == path
|| !other_fv.touched(other)
|| !arity::same_family(path, other_path)
{
continue;
}
let Some(content) = other_fv.version(other) else {
continue;
};
if has_definition(content, &d.name) {
continue;
}
let stale: Vec<CallSite> = arity::call_sites(other_path, content, &d.name)
.into_iter()
.filter(|c| c.args == Some(old.params))
.collect();
if stale.is_empty() {
continue;
}
out.push(
other_path,
arity_finding(&d.name, &d.entity_type, actor, path, old, new, &stale),
None,
Reference {
entity: d.name.clone(),
defined_in_output: false,
referenced_in_output: true,
},
);
}
}
}
}
out.into_documents()
}
fn arity_finding(
name: &str,
entity_type: &str,
actor: Side,
defining_file: &str,
old: &Signature,
new: &Signature,
stale: &[CallSite],
) -> Finding {
let mut f = Finding::derived("ARITY", name, entity_type);
f.side = Some(actor.wire().to_string());
f.confidence = Some("high".to_string());
f.base = Some(old.text.clone());
match actor {
Side::Ours => f.ours = Some(new.text.clone()),
Side::Theirs => f.theirs = Some(new.text.clone()),
}
let sites = stale
.iter()
.map(|c| format!("line {}: `{}`", c.line, c.text))
.collect::<Vec<_>>()
.join("; ");
f.suggestion = Some(format!(
"{} changed `{name}` in `{defining_file}` from {} to {} parameter(s) (`{}` → `{}`), \
but this file still calls it with {}: {sites}. Update these calls to the new \
signature.",
actor.wire(),
old.params,
new.params,
old.text,
new.text,
old.params,
));
f.related = vec![RelatedRef {
entity: name.to_string(),
entity_type: Some(entity_type.to_string()),
file: Some(defining_file.to_string()),
}];
f
}
fn dangling_finding(
name: &str,
entity_type: &str,
actor: Side,
defining_file: &str,
renamed_to: Option<&str>,
) -> Finding {
let mut f = Finding::derived("DANGLING", name, entity_type);
f.side = Some(actor.wire().to_string());
f.confidence = Some("high".to_string());
f.suggestion = Some(match renamed_to {
Some(to) => format!(
"`{name}` is still called here but {} renamed it to `{to}` in `{defining_file}`, \
and nothing else in the repo defines it. Rewrite these call sites to `{to}`.",
actor.wire()
),
None => format!(
"`{name}` is still called here but {} deleted it from `{defining_file}`, and nothing \
else in the repo defines it. Restore the definition or remove the call sites.",
actor.wire()
),
});
f.related = vec![RelatedRef {
entity: renamed_to.unwrap_or(name).to_string(),
entity_type: Some(entity_type.to_string()),
file: Some(defining_file.to_string()),
}];
f
}
fn shadow_finding(name: &str, entity_type: &str, actor: Side, defining_file: &str) -> Finding {
let mut f = Finding::derived("SHADOW", name, entity_type);
f.side = Some(actor.wire().to_string());
f.confidence = Some("medium".to_string());
f.suggestion = Some(format!(
"`{name}` is called here and was resolving outside this merge (an import or a builtin), \
but {} added a new definition of `{name}` in `{defining_file}`. Check which one this \
call is meant to reach.",
actor.wire()
));
f.related = vec![RelatedRef {
entity: name.to_string(),
entity_type: Some(entity_type.to_string()),
file: Some(defining_file.to_string()),
}];
f
}
#[derive(Default)]
struct DocumentParts {
findings: Vec<Finding>,
renames: Vec<Rename>,
references: Vec<Reference>,
}
#[derive(Default)]
struct FindingSink {
per_file: BTreeMap<String, DocumentParts>,
seen: HashSet<(String, String, String)>,
}
impl FindingSink {
fn push(&mut self, file: &str, finding: Finding, rename: Option<Rename>, reference: Reference) {
let key = (
file.to_string(),
finding.class.clone(),
finding.entity.clone(),
);
if !self.seen.insert(key) {
return;
}
let slot = self.per_file.entry(file.to_string()).or_default();
slot.findings.push(finding);
if let Some(r) = rename {
if !slot.renames.contains(&r) {
slot.renames.push(r);
}
}
if !slot.references.contains(&reference) {
slot.references.push(reference);
}
}
fn into_documents(self) -> Vec<Findings> {
self.per_file
.into_iter()
.map(|(file, parts)| {
let confidence = if parts
.findings
.iter()
.any(|f| f.class == "DANGLING" || f.class == "ARITY")
{
"low"
} else {
"medium"
};
let version = if parts.findings.iter().any(|f| f.class == "ARITY") {
SCHEMA_VERSION_1_4
} else {
SCHEMA_VERSION_1_1
};
Findings {
schema_version: version.to_string(),
file,
scope: Some("repo".to_string()),
clean: true,
confidence: confidence.to_string(),
findings: parts.findings,
ops: Vec::new(),
bindings: Bindings {
renames: parts.renames,
references: parts.references,
},
}
})
.collect()
}
}
pub fn read_dir_tree(root: &std::path::Path) -> std::io::Result<Tree> {
fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Tree) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name == ".git" || name == "target" || name == "node_modules" {
continue;
}
if path.is_dir() {
walk(root, &path, out)?;
} else if let Ok(rel) = path.strip_prefix(root) {
let rel = rel.to_string_lossy().replace('\\', "/");
if !is_supported(&rel) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&path) {
out.insert(rel, content);
}
}
}
Ok(())
}
let mut out = Tree::new();
walk(root, root, &mut out)?;
Ok(out)
}
pub fn total_findings(docs: &[Findings]) -> usize {
docs.iter().map(|d| d.findings.len()).sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(pairs: &[(&str, &str)]) -> Tree {
pairs
.iter()
.map(|(p, c)| (p.to_string(), c.to_string()))
.collect()
}
#[test]
fn a_rename_in_one_file_dangles_a_caller_in_another() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[
("a.py", "def get_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let theirs = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
(
"b.py",
"def report():\n return 1\n\n\ndef summary():\n return fetch_user(1)\n",
),
]);
let docs = check(&base, &ours, &theirs);
assert_eq!(docs.len(), 1, "the caller's file is the one at risk");
assert_eq!(docs[0].file, "b.py");
let f = &docs[0].findings[0];
assert_eq!(f.class, "DANGLING");
assert_eq!(f.entity, "fetch_user");
assert_eq!(f.side.as_deref(), Some("ours"));
assert_eq!(f.related[0].file.as_deref(), Some("a.py"));
assert_eq!(docs[0].bindings.renames[0].to, "get_user");
}
#[test]
fn a_delete_in_one_file_dangles_a_caller_in_another() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[("a.py", "\n"), ("b.py", "def report():\n return 1\n")]);
let theirs = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return fetch_user(1)\n"),
]);
let docs = check(&base, &ours, &theirs);
let f = &docs[0].findings[0];
assert_eq!(f.class, "DANGLING");
assert!(f.suggestion.as_ref().unwrap().contains("deleted"));
}
#[test]
fn a_surviving_definition_elsewhere_is_not_dangling() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("c.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[
("a.py", "def get_user(uid):\n return uid\n"),
("c.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let theirs = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("c.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return fetch_user(1)\n"),
]);
assert!(check(&base, &ours, &theirs).is_empty());
}
#[test]
fn an_untouched_caller_is_not_reported() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return fetch_user(1)\n"),
]);
let ours = tree(&[
("a.py", "def get_user(uid):\n return uid\n"),
("b.py", "def report():\n return fetch_user(1)\n"),
]);
let theirs = base.clone();
assert!(check(&base, &ours, &theirs).is_empty());
}
#[test]
fn attribute_access_and_bare_mentions_do_not_count_as_calls() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[
("a.py", "def get_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let theirs = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
(
"b.py",
"def report():\n log('fetch_user')\n return db.fetch_user(1)\n",
),
]);
assert!(check(&base, &ours, &theirs).is_empty());
}
#[test]
fn a_new_definition_capturing_another_sides_call_is_shadow() {
let base = tree(&[
("a.py", "def existing():\n return 0\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[
(
"a.py",
"def existing():\n return 0\n\n\ndef render(x):\n return x\n",
),
("b.py", "def report():\n return 1\n"),
]);
let theirs = tree(&[
("a.py", "def existing():\n return 0\n"),
("b.py", "def report():\n return render(1)\n"),
]);
let docs = check(&base, &ours, &theirs);
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].findings[0].class, "SHADOW");
assert_eq!(docs[0].findings[0].entity, "render");
}
#[test]
fn a_parameter_added_in_one_file_flags_an_old_arity_call_in_another() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[
("a.py", "def fetch_user(uid, tenant):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let theirs = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
(
"b.py",
"def report():\n return fetch_user(1)\n\n\ndef other():\n return fetch_user(1, 2)\n",
),
]);
let docs = check(&base, &ours, &theirs);
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].file, "b.py");
assert_eq!(docs[0].schema_version, "1.4.0");
let f = &docs[0].findings[0];
assert_eq!(f.class, "ARITY");
assert_eq!(f.side.as_deref(), Some("ours"));
assert_eq!(f.base.as_deref(), Some("def fetch_user(uid)"));
assert_eq!(f.ours.as_deref(), Some("def fetch_user(uid, tenant)"));
let s = f.suggestion.as_ref().unwrap();
assert!(s.contains("line 2: `fetch_user(1)`"), "{s}");
assert!(
!s.contains("fetch_user(1, 2)"),
"a call already at the new arity is fine"
);
}
#[test]
fn a_defaulted_new_parameter_is_not_an_arity_break() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[
(
"a.py",
"def fetch_user(uid, tenant=None):\n return uid\n",
),
("b.py", "def report():\n return 1\n"),
]);
let theirs = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return fetch_user(1)\n"),
]);
assert!(check(&base, &ours, &theirs).is_empty());
}
#[test]
fn unsupported_files_are_skipped_rather_than_guessed_at() {
let base = tree(&[("a.bin", "def fetch_user(uid): pass\n")]);
let ours = tree(&[("a.bin", "def get_user(uid): pass\n")]);
let theirs = tree(&[("a.bin", "def fetch_user(uid): pass\nfetch_user(1)\n")]);
assert!(check(&base, &ours, &theirs).is_empty());
}
#[test]
fn documents_conform_to_the_schemas_required_keys() {
let base = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let ours = tree(&[
("a.py", "def get_user(uid):\n return uid\n"),
("b.py", "def report():\n return 1\n"),
]);
let theirs = tree(&[
("a.py", "def fetch_user(uid):\n return uid\n"),
("b.py", "def report():\n return fetch_user(1)\n"),
]);
let docs = check(&base, &ours, &theirs);
let v = serde_json::to_value(&docs[0]).unwrap();
for key in [
"schema_version",
"file",
"clean",
"confidence",
"findings",
"ops",
"bindings",
] {
assert!(v.get(key).is_some(), "missing required key {key}");
}
assert_eq!(v["scope"], "repo");
assert_eq!(v["schema_version"], "1.1.0");
}
}