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