Skip to main content

weave_cli/
repo_scope.rs

1//! The repo-scope binding pass — weave's answer to the per-file driver's one
2//! structural blind spot.
3//!
4//! A git merge driver is invoked **once per file**, with exactly three blobs,
5//! so it only ever sees one file's contents. A rename in one file whose caller
6//! lives in another can merge cleanly per file and still leave the program
7//! broken:
8//!
9//! > `a.py` renames `fetch_user` → `get_user`; `b.py` — untouched by that side,
10//! > edited by the other — still calls `fetch_user(1)`. Both files merge
11//! > cleanly. The program is broken. No per-file driver can see it, because
12//! > neither invocation is given both files.
13//!
14//! This module closes that gap by running the *same* def/use evidence rules
15//! ([`weave_core::binding`] — literally the same functions the per-file pass
16//! calls, not a restatement of them) over the whole repo instead of one file.
17//! It changes nothing about the rule; it only gives it the domain it always
18//! assumed. One owner for that evidence is what makes the two passes'
19//! verdicts comparable at all.
20//!
21//! What it does **not** do: it never resolves, rewrites or merges anything. It
22//! is a read-side pass and emits findings only — `DANGLING` (a use lost its
23//! definition), `SHADOW` (a use may rebind to a different definition) and
24//! `ARITY` (a use still passes the argument count a definition no longer
25//! takes; see [`crate::arity`]).
26//! `DUP` and `ORDER` need a module/import model this pass does not have,
27//! so it does not emit them.
28
29use std::collections::{BTreeMap, BTreeSet, HashSet};
30
31use rayon::prelude::*;
32use sem_core::model::change::ChangeType;
33use sem_core::model::entity::SemanticEntity;
34use sem_core::model::identity::match_entities;
35
36use crate::arity::{self, CallSite, Signature};
37use crate::parsers::entities_of;
38use crate::wire::{
39    Bindings, Finding, Findings, Reference, RelatedRef, Rename, SCHEMA_VERSION_1_1,
40    SCHEMA_VERSION_1_4,
41};
42use weave_core::binding::{has_call_reference, has_definition};
43
44/// One version of the repository: path → content. Only files that survived
45/// into that version appear.
46pub type Tree = BTreeMap<String, String>;
47
48/// Which version of the merge triple a fact came from.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(crate) enum Side {
51    Ours,
52    Theirs,
53}
54
55impl Side {
56    fn wire(self) -> &'static str {
57        match self {
58            Side::Ours => "ours",
59            Side::Theirs => "theirs",
60        }
61    }
62    fn other(self) -> Side {
63        match self {
64            Side::Ours => Side::Theirs,
65            Side::Theirs => Side::Ours,
66        }
67    }
68}
69
70/// A definition weave can see: a top-level entity in a supported file.
71#[derive(Debug, Clone)]
72struct Def {
73    name: String,
74    entity_type: String,
75    /// The exact parameter count and signature line, for a function in a
76    /// language [`arity`] reads; `None` whenever the count is not exact.
77    signature: Option<Signature>,
78}
79
80/// Per-file, per-version def sets plus the raw text (the text is what the
81/// evidence rules actually run on — the parser tells us *what* is defined, the
82/// text tells us *what is used*).
83#[derive(Default)]
84struct FileVersions {
85    base: Option<String>,
86    ours: Option<String>,
87    theirs: Option<String>,
88    base_defs: Vec<Def>,
89    ours_defs: Vec<Def>,
90    theirs_defs: Vec<Def>,
91    /// base name → new name, per side, from sem-core's matcher.
92    ours_renames: BTreeMap<String, String>,
93    theirs_renames: BTreeMap<String, String>,
94    /// Whether each side changed (or added) the file, decided once: the
95    /// passes below ask it for every file per definition.
96    ours_touched: bool,
97    theirs_touched: bool,
98}
99
100impl FileVersions {
101    fn version(&self, side: Side) -> Option<&String> {
102        match side {
103            Side::Ours => self.ours.as_ref(),
104            Side::Theirs => self.theirs.as_ref(),
105        }
106    }
107    fn defs(&self, side: Side) -> &[Def] {
108        match side {
109            Side::Ours => &self.ours_defs,
110            Side::Theirs => &self.theirs_defs,
111        }
112    }
113    fn renames(&self, side: Side) -> &BTreeMap<String, String> {
114        match side {
115            Side::Ours => &self.ours_renames,
116            Side::Theirs => &self.theirs_renames,
117        }
118    }
119    /// Did `side` change (or add) this file relative to base?
120    fn touched(&self, side: Side) -> bool {
121        match side {
122            Side::Ours => self.ours_touched,
123            Side::Theirs => self.theirs_touched,
124        }
125    }
126}
127
128fn defs_of(entities: &[SemanticEntity], path: &str) -> Vec<Def> {
129    entities
130        .iter()
131        .map(|e| Def {
132            name: e.name.clone(),
133            entity_type: e.entity_type.clone(),
134            signature: (e.entity_type == "function")
135                .then(|| arity::params(path, &e.name, &e.content))
136                .flatten(),
137        })
138        .collect()
139}
140
141/// base name → new name, for entities sem-core's matcher paired across a
142/// rename. This is the *same* matcher the per-file pass uses, so the two
143/// passes agree about what counts as a rename: findings are relative to how
144/// renames are matched, so both passes must use the one matcher or their
145/// verdicts are not comparable.
146fn rename_map(
147    before: &[SemanticEntity],
148    after: &[SemanticEntity],
149    path: &str,
150) -> BTreeMap<String, String> {
151    let mut map = BTreeMap::new();
152    for c in match_entities(before, after, path, None, None, None).changes {
153        if c.change_type == ChangeType::Renamed {
154            if let Some(old) = c.old_entity_name.clone() {
155                if old != c.entity_name && !old.is_empty() && !c.entity_name.is_empty() {
156                    map.insert(old, c.entity_name.clone());
157                }
158            }
159        }
160    }
161    map
162}
163
164pub(crate) use crate::parsers::is_supported;
165
166/// Run the repo-scope binding pass over a merge triple of whole trees.
167///
168/// Returns one findings document per file that has something to say, each
169/// conforming to `weave-findings.schema.json` with `scope: "repo"`. Empty means
170/// weave found nothing — never that nothing is wrong.
171pub fn check(base: &Tree, ours: &Tree, theirs: &Tree) -> Vec<Findings> {
172    // ---- 1. Build per-file def sets for the WHOLE repo, all three versions.
173    let mut paths: BTreeSet<&String> = BTreeSet::new();
174    paths.extend(base.keys());
175    paths.extend(ours.keys());
176    paths.extend(theirs.keys());
177
178    //
179    // Most of a repository is a file neither side touched, whose three
180    // versions are one text: it is read once, and a side that left a file as
181    // base has no renames in it. Files are independent of each other, so they
182    // are read in parallel; the map they land in keeps the order.
183    let paths: Vec<&String> = paths.into_iter().filter(|p| is_supported(p)).collect();
184    let files: BTreeMap<String, FileVersions> = paths
185        .par_iter()
186        .map(|&path| {
187            let mut fv = FileVersions {
188                base: base.get(path).cloned(),
189                ours: ours.get(path).cloned(),
190                theirs: theirs.get(path).cloned(),
191                ..Default::default()
192            };
193            let text = |v: &Option<String>| v.as_deref().unwrap_or("").to_string();
194            let (b, o, t) = (text(&fv.base), text(&fv.ours), text(&fv.theirs));
195            let base_e = entities_of(path, &b);
196            let base_defs = defs_of(&base_e, path);
197            let side = |side: &str| -> (BTreeMap<String, String>, Vec<Def>) {
198                if side == b {
199                    return (BTreeMap::new(), base_defs.clone());
200                }
201                let e = entities_of(path, side);
202                (rename_map(&base_e, &e, path), defs_of(&e, path))
203            };
204            (fv.ours_renames, fv.ours_defs) = side(&o);
205            (fv.theirs_renames, fv.theirs_defs) = if t == o {
206                (fv.ours_renames.clone(), fv.ours_defs.clone())
207            } else {
208                side(&t)
209            };
210            fv.base_defs = base_defs;
211            fv.ours_touched = fv.ours != fv.base;
212            fv.theirs_touched = fv.theirs != fv.base;
213            (path.clone(), fv)
214        })
215        .collect();
216
217    // ---- 2. The post-merge definition set, repo-wide.
218    //
219    // A name is still bound after the merge if *some* file still defines it.
220    // Per file, the merged version is whichever side changed it; when both
221    // changed it we take the union, which is the conservative direction: it can
222    // only suppress findings, never invent them.
223    let mut merged_defs: HashSet<String> = HashSet::new();
224    let mut base_defs_all: HashSet<String> = HashSet::new();
225    for fv in files.values() {
226        for d in &fv.base_defs {
227            base_defs_all.insert(d.name.clone());
228        }
229        let (o, t) = (fv.touched(Side::Ours), fv.touched(Side::Theirs));
230        let survivors: Vec<&Def> = match (o, t) {
231            (true, true) => fv.ours_defs.iter().chain(fv.theirs_defs.iter()).collect(),
232            (true, false) => fv.ours_defs.iter().collect(),
233            (false, true) => fv.theirs_defs.iter().collect(),
234            (false, false) => fv.base_defs.iter().collect(),
235        };
236        for d in survivors {
237            merged_defs.insert(d.name.clone());
238        }
239    }
240
241    // Definitions each side can see anywhere in its own tree — used to decide
242    // whether a caller's name was already bound on that side.
243    let side_defs = |side: Side| -> HashSet<String> {
244        files
245            .values()
246            .filter(|fv| fv.version(side).is_some())
247            .flat_map(|fv| fv.defs(side))
248            .map(|def| def.name.clone())
249            .collect()
250    };
251    let ours_defs_all = side_defs(Side::Ours);
252    let theirs_defs_all = side_defs(Side::Theirs);
253
254    let mut out: FindingSink = FindingSink::default();
255
256    // ---- 3. DANGLING: one side removed a definition, the other side's changed
257    //         code still calls it, and nothing in the repo redefines it.
258    for actor in [Side::Ours, Side::Theirs] {
259        let other = actor.other();
260        for (path, fv) in &files {
261            if !fv.touched(actor) || fv.base.is_none() {
262                continue;
263            }
264            let actor_names: HashSet<&str> =
265                fv.defs(actor).iter().map(|d| d.name.as_str()).collect();
266            let renames = fv.renames(actor);
267            for d in &fv.base_defs {
268                if actor_names.contains(d.name.as_str()) {
269                    continue;
270                }
271                // Still defined somewhere post-merge: the use is bound, so this
272                // is not DANGLING (it may be DUP, which is not decidable here).
273                if merged_defs.contains(&d.name) {
274                    continue;
275                }
276                let renamed_to = renames.get(&d.name).cloned();
277                for (other_path, other_fv) in &files {
278                    if other_path == path || !other_fv.touched(other) {
279                        continue;
280                    }
281                    let Some(content) = other_fv.version(other) else {
282                        continue;
283                    };
284                    // The caller's own file defines it: bound locally, skip —
285                    // the same guard the per-file pass applies.
286                    if has_definition(content, &d.name) {
287                        continue;
288                    }
289                    if !has_call_reference(content, &d.name) {
290                        continue;
291                    }
292                    out.push(
293                        other_path,
294                        dangling_finding(
295                            &d.name,
296                            &d.entity_type,
297                            actor,
298                            path,
299                            renamed_to.as_deref(),
300                        ),
301                        renamed_to.as_ref().map(|to| Rename {
302                            from: d.name.clone(),
303                            to: to.clone(),
304                            side: actor.wire().to_string(),
305                        }),
306                        Reference {
307                            entity: d.name.clone(),
308                            defined_in_output: false,
309                            referenced_in_output: true,
310                        },
311                    );
312                }
313            }
314        }
315    }
316
317    // ---- 4. SHADOW: one side introduced a definition of a name that did not
318    //         exist in base, and the other side's changed code calls that name
319    //         while binding it to something else (an import, a builtin).
320    for actor in [Side::Ours, Side::Theirs] {
321        let other = actor.other();
322        let other_defs_all = match other {
323            Side::Ours => &ours_defs_all,
324            Side::Theirs => &theirs_defs_all,
325        };
326        for (path, fv) in &files {
327            if !fv.touched(actor) {
328                continue;
329            }
330            let base_names: HashSet<&str> = fv.base_defs.iter().map(|d| d.name.as_str()).collect();
331            let rename_targets: HashSet<&str> =
332                fv.renames(actor).values().map(|s| s.as_str()).collect();
333            for d in fv.defs(actor) {
334                if base_names.contains(d.name.as_str()) || rename_targets.contains(d.name.as_str())
335                {
336                    continue;
337                }
338                // Not new to the repo: the name already resolved somewhere.
339                if base_defs_all.contains(&d.name) {
340                    continue;
341                }
342                // The other side defines it too — a collision, not a capture.
343                if other_defs_all.contains(&d.name) {
344                    continue;
345                }
346                for (other_path, other_fv) in &files {
347                    if other_path == path || !other_fv.touched(other) {
348                        continue;
349                    }
350                    let Some(content) = other_fv.version(other) else {
351                        continue;
352                    };
353                    if has_definition(content, &d.name) || !has_call_reference(content, &d.name) {
354                        continue;
355                    }
356                    out.push(
357                        other_path,
358                        shadow_finding(&d.name, &d.entity_type, actor, path),
359                        None,
360                        Reference {
361                            entity: d.name.clone(),
362                            defined_in_output: false,
363                            referenced_in_output: true,
364                        },
365                    );
366                }
367            }
368        }
369    }
370
371    // ---- 5. ARITY: one side changed how many parameters a function takes, and
372    //         the other side's changed code calls it with the OLD count from a
373    //         file the first side never touched. The name still binds, so
374    //         DANGLING is silent; the call is wrong all the same.
375    //
376    // Every condition below exists to keep the count exact and the binding
377    // unambiguous, because a wrong ARITY finding would send an agent to "fix"
378    // a correct call:
379    //   * the definition file was changed by the actor ONLY, so its merged
380    //     version is the actor's and the new count is the one that ships;
381    //   * the name is defined in exactly one file of the base repo, so a bare
382    //     call cannot be reaching some other definition;
383    //   * both counts are exact (no defaults, variadics, receivers — see
384    //     [`arity::params`]) and differ;
385    //   * the caller file is in the same language, was changed by the other
386    //     side, does not define the name itself, and the call's own argument
387    //     count is exact and equals the OLD count.
388    let mut defined_in: BTreeMap<&str, usize> = BTreeMap::new();
389    for fv in files.values() {
390        let mut names: HashSet<&str> = HashSet::new();
391        for d in &fv.base_defs {
392            names.insert(d.name.as_str());
393        }
394        for n in names {
395            *defined_in.entry(n).or_default() += 1;
396        }
397    }
398    for actor in [Side::Ours, Side::Theirs] {
399        let other = actor.other();
400        for (path, fv) in &files {
401            if !fv.touched(actor) || fv.touched(other) || fv.base.is_none() {
402                continue;
403            }
404            for d in fv.defs(actor) {
405                let Some(new) = &d.signature else { continue };
406                if defined_in.get(d.name.as_str()) != Some(&1) {
407                    continue;
408                }
409                let Some(old) = fv
410                    .base_defs
411                    .iter()
412                    .find(|b| b.name == d.name)
413                    .and_then(|b| b.signature.as_ref())
414                else {
415                    continue;
416                };
417                if old.params == new.params {
418                    continue;
419                }
420                for (other_path, other_fv) in &files {
421                    if other_path == path
422                        || !other_fv.touched(other)
423                        || !arity::same_family(path, other_path)
424                    {
425                        continue;
426                    }
427                    let Some(content) = other_fv.version(other) else {
428                        continue;
429                    };
430                    if has_definition(content, &d.name) {
431                        continue;
432                    }
433                    let stale: Vec<CallSite> = arity::call_sites(other_path, content, &d.name)
434                        .into_iter()
435                        .filter(|c| c.args == Some(old.params))
436                        .collect();
437                    if stale.is_empty() {
438                        continue;
439                    }
440                    out.push(
441                        other_path,
442                        arity_finding(&d.name, &d.entity_type, actor, path, old, new, &stale),
443                        None,
444                        Reference {
445                            entity: d.name.clone(),
446                            defined_in_output: false,
447                            referenced_in_output: true,
448                        },
449                    );
450                }
451            }
452        }
453    }
454
455    out.into_documents()
456}
457
458fn arity_finding(
459    name: &str,
460    entity_type: &str,
461    actor: Side,
462    defining_file: &str,
463    old: &Signature,
464    new: &Signature,
465    stale: &[CallSite],
466) -> Finding {
467    let mut f = Finding::derived("ARITY", name, entity_type);
468    f.side = Some(actor.wire().to_string());
469    // Both counts are exact and the name binds to one definition repo-wide;
470    // what weave cannot see is a runtime that tolerates the mismatch.
471    f.confidence = Some("high".to_string());
472    // The texts are the signature before and after, on the sides that wrote
473    // them: base is the old one, the actor's side is the new one.
474    f.base = Some(old.text.clone());
475    match actor {
476        Side::Ours => f.ours = Some(new.text.clone()),
477        Side::Theirs => f.theirs = Some(new.text.clone()),
478    }
479    let sites = stale
480        .iter()
481        .map(|c| format!("line {}: `{}`", c.line, c.text))
482        .collect::<Vec<_>>()
483        .join("; ");
484    f.suggestion = Some(format!(
485        "{} changed `{name}` in `{defining_file}` from {} to {} parameter(s) (`{}` → `{}`), \
486         but this file still calls it with {}: {sites}. Update these calls to the new \
487         signature.",
488        actor.wire(),
489        old.params,
490        new.params,
491        old.text,
492        new.text,
493        old.params,
494    ));
495    f.related = vec![RelatedRef {
496        entity: name.to_string(),
497        entity_type: Some(entity_type.to_string()),
498        file: Some(defining_file.to_string()),
499    }];
500    f
501}
502
503fn dangling_finding(
504    name: &str,
505    entity_type: &str,
506    actor: Side,
507    defining_file: &str,
508    renamed_to: Option<&str>,
509) -> Finding {
510    let mut f = Finding::derived("DANGLING", name, entity_type);
511    f.side = Some(actor.wire().to_string());
512    f.confidence = Some("high".to_string());
513    f.suggestion = Some(match renamed_to {
514        Some(to) => format!(
515            "`{name}` is still called here but {} renamed it to `{to}` in `{defining_file}`, \
516             and nothing else in the repo defines it. Rewrite these call sites to `{to}`.",
517            actor.wire()
518        ),
519        None => format!(
520            "`{name}` is still called here but {} deleted it from `{defining_file}`, and nothing \
521             else in the repo defines it. Restore the definition or remove the call sites.",
522            actor.wire()
523        ),
524    });
525    f.related = vec![RelatedRef {
526        entity: renamed_to.unwrap_or(name).to_string(),
527        entity_type: Some(entity_type.to_string()),
528        file: Some(defining_file.to_string()),
529    }];
530    f
531}
532
533fn shadow_finding(name: &str, entity_type: &str, actor: Side, defining_file: &str) -> Finding {
534    let mut f = Finding::derived("SHADOW", name, entity_type);
535    f.side = Some(actor.wire().to_string());
536    // Cross-file capture depends on the language's import/module rules, which
537    // weave does not model. Medium is the honest ceiling here.
538    f.confidence = Some("medium".to_string());
539    f.suggestion = Some(format!(
540        "`{name}` is called here and was resolving outside this merge (an import or a builtin), \
541         but {} added a new definition of `{name}` in `{defining_file}`. Check which one this \
542         call is meant to reach.",
543        actor.wire()
544    ));
545    f.related = vec![RelatedRef {
546        entity: name.to_string(),
547        entity_type: Some(entity_type.to_string()),
548        file: Some(defining_file.to_string()),
549    }];
550    f
551}
552
553/// Everything one document accumulates: its findings and the binding facts
554/// that justify them.
555#[derive(Default)]
556struct DocumentParts {
557    findings: Vec<Finding>,
558    renames: Vec<Rename>,
559    references: Vec<Reference>,
560}
561
562/// Accumulates findings per file and folds them into schema documents.
563#[derive(Default)]
564struct FindingSink {
565    per_file: BTreeMap<String, DocumentParts>,
566    /// (file, class, entity) — one finding per breakage, however many callers
567    /// or sides witness it.
568    seen: HashSet<(String, String, String)>,
569}
570
571impl FindingSink {
572    fn push(&mut self, file: &str, finding: Finding, rename: Option<Rename>, reference: Reference) {
573        let key = (
574            file.to_string(),
575            finding.class.clone(),
576            finding.entity.clone(),
577        );
578        if !self.seen.insert(key) {
579            return;
580        }
581        let slot = self.per_file.entry(file.to_string()).or_default();
582        slot.findings.push(finding);
583        if let Some(r) = rename {
584            if !slot.renames.contains(&r) {
585                slot.renames.push(r);
586            }
587        }
588        if !slot.references.contains(&reference) {
589            slot.references.push(reference);
590        }
591    }
592
593    fn into_documents(self) -> Vec<Findings> {
594        self.per_file
595            .into_iter()
596            .map(|(file, parts)| {
597                // A repo-scope document never describes a text merge, so
598                // `clean` is about markers and is vacuously true; the whole
599                // point of the schema is that clean and safe differ.
600                let confidence = if parts
601                    .findings
602                    .iter()
603                    .any(|f| f.class == "DANGLING" || f.class == "ARITY")
604                {
605                    "low"
606                } else {
607                    "medium"
608                };
609                // A producer declares the lowest version whose vocabulary it
610                // uses: ARITY is 1.4.0, everything else here is 1.1.0.
611                let version = if parts.findings.iter().any(|f| f.class == "ARITY") {
612                    SCHEMA_VERSION_1_4
613                } else {
614                    SCHEMA_VERSION_1_1
615                };
616                Findings {
617                    schema_version: version.to_string(),
618                    file,
619                    scope: Some("repo".to_string()),
620                    clean: true,
621                    confidence: confidence.to_string(),
622                    findings: parts.findings,
623                    // A check performs no merge, so there is no per-entity
624                    // composition record to report. Empty, never absent.
625                    ops: Vec::new(),
626                    bindings: Bindings {
627                        renames: parts.renames,
628                        references: parts.references,
629                    },
630                }
631            })
632            .collect()
633    }
634}
635
636/// Convenience: the merge triple as directory trees on disk.
637pub fn read_dir_tree(root: &std::path::Path) -> std::io::Result<Tree> {
638    fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Tree) -> std::io::Result<()> {
639        for entry in std::fs::read_dir(dir)? {
640            let entry = entry?;
641            let path = entry.path();
642            let name = entry.file_name();
643            let name = name.to_string_lossy();
644            if name == ".git" || name == "target" || name == "node_modules" {
645                continue;
646            }
647            if path.is_dir() {
648                walk(root, &path, out)?;
649            } else if let Ok(rel) = path.strip_prefix(root) {
650                let rel = rel.to_string_lossy().replace('\\', "/");
651                if !is_supported(&rel) {
652                    continue;
653                }
654                if let Ok(content) = std::fs::read_to_string(&path) {
655                    out.insert(rel, content);
656                }
657            }
658        }
659        Ok(())
660    }
661    let mut out = Tree::new();
662    walk(root, root, &mut out)?;
663    Ok(out)
664}
665
666/// Total finding count across documents — the exit-code decision.
667pub fn total_findings(docs: &[Findings]) -> usize {
668    docs.iter().map(|d| d.findings.len()).sum()
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    fn tree(pairs: &[(&str, &str)]) -> Tree {
676        pairs
677            .iter()
678            .map(|(p, c)| (p.to_string(), c.to_string()))
679            .collect()
680    }
681
682    /// THE blind spot: a rename in one file, a caller in another, both merging
683    /// cleanly per-file.
684    #[test]
685    fn a_rename_in_one_file_dangles_a_caller_in_another() {
686        let base = tree(&[
687            ("a.py", "def fetch_user(uid):\n    return uid\n"),
688            ("b.py", "def report():\n    return 1\n"),
689        ]);
690        // ours renames the definition
691        let ours = tree(&[
692            ("a.py", "def get_user(uid):\n    return uid\n"),
693            ("b.py", "def report():\n    return 1\n"),
694        ]);
695        // theirs adds a caller of the OLD name
696        let theirs = tree(&[
697            ("a.py", "def fetch_user(uid):\n    return uid\n"),
698            (
699                "b.py",
700                "def report():\n    return 1\n\n\ndef summary():\n    return fetch_user(1)\n",
701            ),
702        ]);
703
704        let docs = check(&base, &ours, &theirs);
705        assert_eq!(docs.len(), 1, "the caller's file is the one at risk");
706        assert_eq!(docs[0].file, "b.py");
707        let f = &docs[0].findings[0];
708        assert_eq!(f.class, "DANGLING");
709        assert_eq!(f.entity, "fetch_user");
710        assert_eq!(f.side.as_deref(), Some("ours"));
711        assert_eq!(f.related[0].file.as_deref(), Some("a.py"));
712        assert_eq!(docs[0].bindings.renames[0].to, "get_user");
713    }
714
715    #[test]
716    fn a_delete_in_one_file_dangles_a_caller_in_another() {
717        let base = tree(&[
718            ("a.py", "def fetch_user(uid):\n    return uid\n"),
719            ("b.py", "def report():\n    return 1\n"),
720        ]);
721        let ours = tree(&[("a.py", "\n"), ("b.py", "def report():\n    return 1\n")]);
722        let theirs = tree(&[
723            ("a.py", "def fetch_user(uid):\n    return uid\n"),
724            ("b.py", "def report():\n    return fetch_user(1)\n"),
725        ]);
726        let docs = check(&base, &ours, &theirs);
727        let f = &docs[0].findings[0];
728        assert_eq!(f.class, "DANGLING");
729        assert!(f.suggestion.as_ref().unwrap().contains("deleted"));
730    }
731
732    #[test]
733    fn a_surviving_definition_elsewhere_is_not_dangling() {
734        // c.py still defines fetch_user, so the caller is bound.
735        let base = tree(&[
736            ("a.py", "def fetch_user(uid):\n    return uid\n"),
737            ("c.py", "def fetch_user(uid):\n    return uid\n"),
738            ("b.py", "def report():\n    return 1\n"),
739        ]);
740        let ours = tree(&[
741            ("a.py", "def get_user(uid):\n    return uid\n"),
742            ("c.py", "def fetch_user(uid):\n    return uid\n"),
743            ("b.py", "def report():\n    return 1\n"),
744        ]);
745        let theirs = tree(&[
746            ("a.py", "def fetch_user(uid):\n    return uid\n"),
747            ("c.py", "def fetch_user(uid):\n    return uid\n"),
748            ("b.py", "def report():\n    return fetch_user(1)\n"),
749        ]);
750        assert!(check(&base, &ours, &theirs).is_empty());
751    }
752
753    #[test]
754    fn an_untouched_caller_is_not_reported() {
755        // b.py references the old name but NEITHER side touched b.py: that is a
756        // pre-existing repo state, not something this merge composed.
757        let base = tree(&[
758            ("a.py", "def fetch_user(uid):\n    return uid\n"),
759            ("b.py", "def report():\n    return fetch_user(1)\n"),
760        ]);
761        let ours = tree(&[
762            ("a.py", "def get_user(uid):\n    return uid\n"),
763            ("b.py", "def report():\n    return fetch_user(1)\n"),
764        ]);
765        let theirs = base.clone();
766        assert!(check(&base, &ours, &theirs).is_empty());
767    }
768
769    #[test]
770    fn attribute_access_and_bare_mentions_do_not_count_as_calls() {
771        let base = tree(&[
772            ("a.py", "def fetch_user(uid):\n    return uid\n"),
773            ("b.py", "def report():\n    return 1\n"),
774        ]);
775        let ours = tree(&[
776            ("a.py", "def get_user(uid):\n    return uid\n"),
777            ("b.py", "def report():\n    return 1\n"),
778        ]);
779        let theirs = tree(&[
780            ("a.py", "def fetch_user(uid):\n    return uid\n"),
781            (
782                "b.py",
783                "def report():\n    log('fetch_user')\n    return db.fetch_user(1)\n",
784            ),
785        ]);
786        assert!(check(&base, &ours, &theirs).is_empty());
787    }
788
789    #[test]
790    fn a_new_definition_capturing_another_sides_call_is_shadow() {
791        let base = tree(&[
792            ("a.py", "def existing():\n    return 0\n"),
793            ("b.py", "def report():\n    return 1\n"),
794        ]);
795        // ours defines a brand-new `render`
796        let ours = tree(&[
797            (
798                "a.py",
799                "def existing():\n    return 0\n\n\ndef render(x):\n    return x\n",
800            ),
801            ("b.py", "def report():\n    return 1\n"),
802        ]);
803        // theirs adds code that calls `render` — meaning something imported
804        let theirs = tree(&[
805            ("a.py", "def existing():\n    return 0\n"),
806            ("b.py", "def report():\n    return render(1)\n"),
807        ]);
808        let docs = check(&base, &ours, &theirs);
809        assert_eq!(docs.len(), 1);
810        assert_eq!(docs[0].findings[0].class, "SHADOW");
811        assert_eq!(docs[0].findings[0].entity, "render");
812    }
813
814    /// The signature blind spot: the name still binds, so DANGLING is silent,
815    /// and the call passes the argument count the definition no longer takes.
816    #[test]
817    fn a_parameter_added_in_one_file_flags_an_old_arity_call_in_another() {
818        let base = tree(&[
819            ("a.py", "def fetch_user(uid):\n    return uid\n"),
820            ("b.py", "def report():\n    return 1\n"),
821        ]);
822        let ours = tree(&[
823            ("a.py", "def fetch_user(uid, tenant):\n    return uid\n"),
824            ("b.py", "def report():\n    return 1\n"),
825        ]);
826        let theirs = tree(&[
827            ("a.py", "def fetch_user(uid):\n    return uid\n"),
828            (
829                "b.py",
830                "def report():\n    return fetch_user(1)\n\n\ndef other():\n    return fetch_user(1, 2)\n",
831            ),
832        ]);
833        let docs = check(&base, &ours, &theirs);
834        assert_eq!(docs.len(), 1);
835        assert_eq!(docs[0].file, "b.py");
836        assert_eq!(docs[0].schema_version, "1.4.0");
837        let f = &docs[0].findings[0];
838        assert_eq!(f.class, "ARITY");
839        assert_eq!(f.side.as_deref(), Some("ours"));
840        assert_eq!(f.base.as_deref(), Some("def fetch_user(uid)"));
841        assert_eq!(f.ours.as_deref(), Some("def fetch_user(uid, tenant)"));
842        let s = f.suggestion.as_ref().unwrap();
843        assert!(s.contains("line 2: `fetch_user(1)`"), "{s}");
844        assert!(
845            !s.contains("fetch_user(1, 2)"),
846            "a call already at the new arity is fine"
847        );
848    }
849
850    /// A default makes the old call still correct: refuse to count it.
851    #[test]
852    fn a_defaulted_new_parameter_is_not_an_arity_break() {
853        let base = tree(&[
854            ("a.py", "def fetch_user(uid):\n    return uid\n"),
855            ("b.py", "def report():\n    return 1\n"),
856        ]);
857        let ours = tree(&[
858            (
859                "a.py",
860                "def fetch_user(uid, tenant=None):\n    return uid\n",
861            ),
862            ("b.py", "def report():\n    return 1\n"),
863        ]);
864        let theirs = tree(&[
865            ("a.py", "def fetch_user(uid):\n    return uid\n"),
866            ("b.py", "def report():\n    return fetch_user(1)\n"),
867        ]);
868        assert!(check(&base, &ours, &theirs).is_empty());
869    }
870
871    #[test]
872    fn unsupported_files_are_skipped_rather_than_guessed_at() {
873        let base = tree(&[("a.bin", "def fetch_user(uid): pass\n")]);
874        let ours = tree(&[("a.bin", "def get_user(uid): pass\n")]);
875        let theirs = tree(&[("a.bin", "def fetch_user(uid): pass\nfetch_user(1)\n")]);
876        assert!(check(&base, &ours, &theirs).is_empty());
877    }
878
879    #[test]
880    fn documents_conform_to_the_schemas_required_keys() {
881        let base = tree(&[
882            ("a.py", "def fetch_user(uid):\n    return uid\n"),
883            ("b.py", "def report():\n    return 1\n"),
884        ]);
885        let ours = tree(&[
886            ("a.py", "def get_user(uid):\n    return uid\n"),
887            ("b.py", "def report():\n    return 1\n"),
888        ]);
889        let theirs = tree(&[
890            ("a.py", "def fetch_user(uid):\n    return uid\n"),
891            ("b.py", "def report():\n    return fetch_user(1)\n"),
892        ]);
893        let docs = check(&base, &ours, &theirs);
894        let v = serde_json::to_value(&docs[0]).unwrap();
895        for key in [
896            "schema_version",
897            "file",
898            "clean",
899            "confidence",
900            "findings",
901            "ops",
902            "bindings",
903        ] {
904            assert!(v.get(key).is_some(), "missing required key {key}");
905        }
906        assert_eq!(v["scope"], "repo");
907        assert_eq!(v["schema_version"], "1.1.0");
908    }
909}