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) and `SHADOW` (a use may rebind to a different definition).
24//! `DUP` and `ORDER` need a module/import model this pass does not have,
25//! so it does not emit them.
26
27use std::collections::{BTreeMap, BTreeSet, HashSet};
28
29use sem_core::model::change::ChangeType;
30use sem_core::model::entity::SemanticEntity;
31use sem_core::model::identity::match_entities;
32
33use crate::parsers::entities_of;
34use crate::wire::{Bindings, Finding, Findings, Reference, RelatedRef, Rename, SCHEMA_VERSION_1_1};
35use weave_core::binding::{has_call_reference, has_definition};
36
37/// One version of the repository: path → content. Only files that survived
38/// into that version appear.
39pub type Tree = BTreeMap<String, String>;
40
41/// Which version of the merge triple a fact came from.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub(crate) enum Side {
44    Ours,
45    Theirs,
46}
47
48impl Side {
49    fn wire(self) -> &'static str {
50        match self {
51            Side::Ours => "ours",
52            Side::Theirs => "theirs",
53        }
54    }
55    fn other(self) -> Side {
56        match self {
57            Side::Ours => Side::Theirs,
58            Side::Theirs => Side::Ours,
59        }
60    }
61}
62
63/// A definition weave can see: a top-level entity in a supported file.
64#[derive(Debug, Clone)]
65struct Def {
66    name: String,
67    entity_type: String,
68}
69
70/// Per-file, per-version def sets plus the raw text (the text is what the
71/// evidence rules actually run on — the parser tells us *what* is defined, the
72/// text tells us *what is used*).
73#[derive(Default)]
74struct FileVersions {
75    base: Option<String>,
76    ours: Option<String>,
77    theirs: Option<String>,
78    base_defs: Vec<Def>,
79    ours_defs: Vec<Def>,
80    theirs_defs: Vec<Def>,
81    /// base name → new name, per side, from sem-core's matcher.
82    ours_renames: BTreeMap<String, String>,
83    theirs_renames: BTreeMap<String, String>,
84}
85
86impl FileVersions {
87    fn version(&self, side: Side) -> Option<&String> {
88        match side {
89            Side::Ours => self.ours.as_ref(),
90            Side::Theirs => self.theirs.as_ref(),
91        }
92    }
93    fn defs(&self, side: Side) -> &[Def] {
94        match side {
95            Side::Ours => &self.ours_defs,
96            Side::Theirs => &self.theirs_defs,
97        }
98    }
99    fn renames(&self, side: Side) -> &BTreeMap<String, String> {
100        match side {
101            Side::Ours => &self.ours_renames,
102            Side::Theirs => &self.theirs_renames,
103        }
104    }
105    /// Did `side` change (or add) this file relative to base?
106    fn touched(&self, side: Side) -> bool {
107        self.version(side) != self.base.as_ref()
108    }
109}
110
111fn defs_of(entities: &[SemanticEntity]) -> Vec<Def> {
112    entities
113        .iter()
114        .map(|e| Def {
115            name: e.name.clone(),
116            entity_type: e.entity_type.clone(),
117        })
118        .collect()
119}
120
121/// base name → new name, for entities sem-core's matcher paired across a
122/// rename. This is the *same* matcher the per-file pass uses, so the two
123/// passes agree about what counts as a rename: findings are relative to how
124/// renames are matched, so both passes must use the one matcher or their
125/// verdicts are not comparable.
126fn rename_map(
127    before: &[SemanticEntity],
128    after: &[SemanticEntity],
129    path: &str,
130) -> BTreeMap<String, String> {
131    let mut map = BTreeMap::new();
132    for c in match_entities(before, after, path, None, None, None).changes {
133        if c.change_type == ChangeType::Renamed {
134            if let Some(old) = c.old_entity_name.clone() {
135                if old != c.entity_name && !old.is_empty() && !c.entity_name.is_empty() {
136                    map.insert(old, c.entity_name.clone());
137                }
138            }
139        }
140    }
141    map
142}
143
144pub(crate) use crate::parsers::is_supported;
145
146/// Run the repo-scope binding pass over a merge triple of whole trees.
147///
148/// Returns one findings document per file that has something to say, each
149/// conforming to `weave-findings.schema.json` with `scope: "repo"`. Empty means
150/// weave found nothing — never that nothing is wrong.
151pub fn check(base: &Tree, ours: &Tree, theirs: &Tree) -> Vec<Findings> {
152    // ---- 1. Build per-file def sets for the WHOLE repo, all three versions.
153    let mut paths: BTreeSet<&String> = BTreeSet::new();
154    paths.extend(base.keys());
155    paths.extend(ours.keys());
156    paths.extend(theirs.keys());
157
158    let mut files: BTreeMap<String, FileVersions> = BTreeMap::new();
159    for path in paths {
160        if !is_supported(path) {
161            continue;
162        }
163        let mut fv = FileVersions {
164            base: base.get(path).cloned(),
165            ours: ours.get(path).cloned(),
166            theirs: theirs.get(path).cloned(),
167            ..Default::default()
168        };
169        let base_e = entities_of(path, fv.base.as_deref().unwrap_or(""));
170        let ours_e = entities_of(path, fv.ours.as_deref().unwrap_or(""));
171        let theirs_e = entities_of(path, fv.theirs.as_deref().unwrap_or(""));
172        fv.ours_renames = rename_map(&base_e, &ours_e, path);
173        fv.theirs_renames = rename_map(&base_e, &theirs_e, path);
174        fv.base_defs = defs_of(&base_e);
175        fv.ours_defs = defs_of(&ours_e);
176        fv.theirs_defs = defs_of(&theirs_e);
177        files.insert(path.clone(), fv);
178    }
179
180    // ---- 2. The post-merge definition set, repo-wide.
181    //
182    // A name is still bound after the merge if *some* file still defines it.
183    // Per file, the merged version is whichever side changed it; when both
184    // changed it we take the union, which is the conservative direction: it can
185    // only suppress findings, never invent them.
186    let mut merged_defs: HashSet<String> = HashSet::new();
187    let mut base_defs_all: HashSet<String> = HashSet::new();
188    for fv in files.values() {
189        for d in &fv.base_defs {
190            base_defs_all.insert(d.name.clone());
191        }
192        let (o, t) = (fv.touched(Side::Ours), fv.touched(Side::Theirs));
193        let survivors: Vec<&Def> = match (o, t) {
194            (true, true) => fv.ours_defs.iter().chain(fv.theirs_defs.iter()).collect(),
195            (true, false) => fv.ours_defs.iter().collect(),
196            (false, true) => fv.theirs_defs.iter().collect(),
197            (false, false) => fv.base_defs.iter().collect(),
198        };
199        for d in survivors {
200            merged_defs.insert(d.name.clone());
201        }
202    }
203
204    // Definitions each side can see anywhere in its own tree — used to decide
205    // whether a caller's name was already bound on that side.
206    let side_defs = |side: Side| -> HashSet<String> {
207        files
208            .values()
209            .filter(|fv| fv.version(side).is_some())
210            .flat_map(|fv| fv.defs(side))
211            .map(|def| def.name.clone())
212            .collect()
213    };
214    let ours_defs_all = side_defs(Side::Ours);
215    let theirs_defs_all = side_defs(Side::Theirs);
216
217    let mut out: FindingSink = FindingSink::default();
218
219    // ---- 3. DANGLING: one side removed a definition, the other side's changed
220    //         code still calls it, and nothing in the repo redefines it.
221    for actor in [Side::Ours, Side::Theirs] {
222        let other = actor.other();
223        for (path, fv) in &files {
224            if !fv.touched(actor) || fv.base.is_none() {
225                continue;
226            }
227            let actor_names: HashSet<&str> =
228                fv.defs(actor).iter().map(|d| d.name.as_str()).collect();
229            let renames = fv.renames(actor);
230            for d in &fv.base_defs {
231                if actor_names.contains(d.name.as_str()) {
232                    continue;
233                }
234                // Still defined somewhere post-merge: the use is bound, so this
235                // is not DANGLING (it may be DUP, which is not decidable here).
236                if merged_defs.contains(&d.name) {
237                    continue;
238                }
239                let renamed_to = renames.get(&d.name).cloned();
240                for (other_path, other_fv) in &files {
241                    if other_path == path || !other_fv.touched(other) {
242                        continue;
243                    }
244                    let Some(content) = other_fv.version(other) else {
245                        continue;
246                    };
247                    // The caller's own file defines it: bound locally, skip —
248                    // the same guard the per-file pass applies.
249                    if has_definition(content, &d.name) {
250                        continue;
251                    }
252                    if !has_call_reference(content, &d.name) {
253                        continue;
254                    }
255                    out.push(
256                        other_path,
257                        dangling_finding(
258                            &d.name,
259                            &d.entity_type,
260                            actor,
261                            path,
262                            renamed_to.as_deref(),
263                        ),
264                        renamed_to.as_ref().map(|to| Rename {
265                            from: d.name.clone(),
266                            to: to.clone(),
267                            side: actor.wire().to_string(),
268                        }),
269                        Reference {
270                            entity: d.name.clone(),
271                            defined_in_output: false,
272                            referenced_in_output: true,
273                        },
274                    );
275                }
276            }
277        }
278    }
279
280    // ---- 4. SHADOW: one side introduced a definition of a name that did not
281    //         exist in base, and the other side's changed code calls that name
282    //         while binding it to something else (an import, a builtin).
283    for actor in [Side::Ours, Side::Theirs] {
284        let other = actor.other();
285        let other_defs_all = match other {
286            Side::Ours => &ours_defs_all,
287            Side::Theirs => &theirs_defs_all,
288        };
289        for (path, fv) in &files {
290            if !fv.touched(actor) {
291                continue;
292            }
293            let base_names: HashSet<&str> = fv.base_defs.iter().map(|d| d.name.as_str()).collect();
294            let rename_targets: HashSet<&str> =
295                fv.renames(actor).values().map(|s| s.as_str()).collect();
296            for d in fv.defs(actor) {
297                if base_names.contains(d.name.as_str()) || rename_targets.contains(d.name.as_str())
298                {
299                    continue;
300                }
301                // Not new to the repo: the name already resolved somewhere.
302                if base_defs_all.contains(&d.name) {
303                    continue;
304                }
305                // The other side defines it too — a collision, not a capture.
306                if other_defs_all.contains(&d.name) {
307                    continue;
308                }
309                for (other_path, other_fv) in &files {
310                    if other_path == path || !other_fv.touched(other) {
311                        continue;
312                    }
313                    let Some(content) = other_fv.version(other) else {
314                        continue;
315                    };
316                    if has_definition(content, &d.name) || !has_call_reference(content, &d.name) {
317                        continue;
318                    }
319                    out.push(
320                        other_path,
321                        shadow_finding(&d.name, &d.entity_type, actor, path),
322                        None,
323                        Reference {
324                            entity: d.name.clone(),
325                            defined_in_output: false,
326                            referenced_in_output: true,
327                        },
328                    );
329                }
330            }
331        }
332    }
333
334    out.into_documents()
335}
336
337fn dangling_finding(
338    name: &str,
339    entity_type: &str,
340    actor: Side,
341    defining_file: &str,
342    renamed_to: Option<&str>,
343) -> Finding {
344    let mut f = Finding::derived("DANGLING", name, entity_type);
345    f.side = Some(actor.wire().to_string());
346    f.confidence = Some("high".to_string());
347    f.suggestion = Some(match renamed_to {
348        Some(to) => format!(
349            "`{name}` is still called here but {} renamed it to `{to}` in `{defining_file}`, \
350             and nothing else in the repo defines it. Rewrite these call sites to `{to}`.",
351            actor.wire()
352        ),
353        None => format!(
354            "`{name}` is still called here but {} deleted it from `{defining_file}`, and nothing \
355             else in the repo defines it. Restore the definition or remove the call sites.",
356            actor.wire()
357        ),
358    });
359    f.related = vec![RelatedRef {
360        entity: renamed_to.unwrap_or(name).to_string(),
361        entity_type: Some(entity_type.to_string()),
362        file: Some(defining_file.to_string()),
363    }];
364    f
365}
366
367fn shadow_finding(name: &str, entity_type: &str, actor: Side, defining_file: &str) -> Finding {
368    let mut f = Finding::derived("SHADOW", name, entity_type);
369    f.side = Some(actor.wire().to_string());
370    // Cross-file capture depends on the language's import/module rules, which
371    // weave does not model. Medium is the honest ceiling here.
372    f.confidence = Some("medium".to_string());
373    f.suggestion = Some(format!(
374        "`{name}` is called here and was resolving outside this merge (an import or a builtin), \
375         but {} added a new definition of `{name}` in `{defining_file}`. Check which one this \
376         call is meant to reach.",
377        actor.wire()
378    ));
379    f.related = vec![RelatedRef {
380        entity: name.to_string(),
381        entity_type: Some(entity_type.to_string()),
382        file: Some(defining_file.to_string()),
383    }];
384    f
385}
386
387/// Everything one document accumulates: its findings and the binding facts
388/// that justify them.
389#[derive(Default)]
390struct DocumentParts {
391    findings: Vec<Finding>,
392    renames: Vec<Rename>,
393    references: Vec<Reference>,
394}
395
396/// Accumulates findings per file and folds them into schema documents.
397#[derive(Default)]
398struct FindingSink {
399    per_file: BTreeMap<String, DocumentParts>,
400    /// (file, class, entity) — one finding per breakage, however many callers
401    /// or sides witness it.
402    seen: HashSet<(String, String, String)>,
403}
404
405impl FindingSink {
406    fn push(&mut self, file: &str, finding: Finding, rename: Option<Rename>, reference: Reference) {
407        let key = (
408            file.to_string(),
409            finding.class.clone(),
410            finding.entity.clone(),
411        );
412        if !self.seen.insert(key) {
413            return;
414        }
415        let slot = self.per_file.entry(file.to_string()).or_default();
416        slot.findings.push(finding);
417        if let Some(r) = rename {
418            if !slot.renames.contains(&r) {
419                slot.renames.push(r);
420            }
421        }
422        if !slot.references.contains(&reference) {
423            slot.references.push(reference);
424        }
425    }
426
427    fn into_documents(self) -> Vec<Findings> {
428        self.per_file
429            .into_iter()
430            .map(|(file, parts)| {
431                // A repo-scope document never describes a text merge, so
432                // `clean` is about markers and is vacuously true; the whole
433                // point of the schema is that clean and safe differ.
434                let confidence = if parts.findings.iter().any(|f| f.class == "DANGLING") {
435                    "low"
436                } else {
437                    "medium"
438                };
439                Findings {
440                    schema_version: SCHEMA_VERSION_1_1.to_string(),
441                    file,
442                    scope: Some("repo".to_string()),
443                    clean: true,
444                    confidence: confidence.to_string(),
445                    findings: parts.findings,
446                    // A check performs no merge, so there is no per-entity
447                    // composition record to report. Empty, never absent.
448                    ops: Vec::new(),
449                    bindings: Bindings {
450                        renames: parts.renames,
451                        references: parts.references,
452                    },
453                }
454            })
455            .collect()
456    }
457}
458
459/// Convenience: the merge triple as directory trees on disk.
460pub fn read_dir_tree(root: &std::path::Path) -> std::io::Result<Tree> {
461    fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Tree) -> std::io::Result<()> {
462        for entry in std::fs::read_dir(dir)? {
463            let entry = entry?;
464            let path = entry.path();
465            let name = entry.file_name();
466            let name = name.to_string_lossy();
467            if name == ".git" || name == "target" || name == "node_modules" {
468                continue;
469            }
470            if path.is_dir() {
471                walk(root, &path, out)?;
472            } else if let Ok(rel) = path.strip_prefix(root) {
473                let rel = rel.to_string_lossy().replace('\\', "/");
474                if !is_supported(&rel) {
475                    continue;
476                }
477                if let Ok(content) = std::fs::read_to_string(&path) {
478                    out.insert(rel, content);
479                }
480            }
481        }
482        Ok(())
483    }
484    let mut out = Tree::new();
485    walk(root, root, &mut out)?;
486    Ok(out)
487}
488
489/// Total finding count across documents — the exit-code decision.
490pub fn total_findings(docs: &[Findings]) -> usize {
491    docs.iter().map(|d| d.findings.len()).sum()
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    fn tree(pairs: &[(&str, &str)]) -> Tree {
499        pairs
500            .iter()
501            .map(|(p, c)| (p.to_string(), c.to_string()))
502            .collect()
503    }
504
505    /// THE blind spot: a rename in one file, a caller in another, both merging
506    /// cleanly per-file.
507    #[test]
508    fn a_rename_in_one_file_dangles_a_caller_in_another() {
509        let base = tree(&[
510            ("a.py", "def fetch_user(uid):\n    return uid\n"),
511            ("b.py", "def report():\n    return 1\n"),
512        ]);
513        // ours renames the definition
514        let ours = tree(&[
515            ("a.py", "def get_user(uid):\n    return uid\n"),
516            ("b.py", "def report():\n    return 1\n"),
517        ]);
518        // theirs adds a caller of the OLD name
519        let theirs = tree(&[
520            ("a.py", "def fetch_user(uid):\n    return uid\n"),
521            (
522                "b.py",
523                "def report():\n    return 1\n\n\ndef summary():\n    return fetch_user(1)\n",
524            ),
525        ]);
526
527        let docs = check(&base, &ours, &theirs);
528        assert_eq!(docs.len(), 1, "the caller's file is the one at risk");
529        assert_eq!(docs[0].file, "b.py");
530        let f = &docs[0].findings[0];
531        assert_eq!(f.class, "DANGLING");
532        assert_eq!(f.entity, "fetch_user");
533        assert_eq!(f.side.as_deref(), Some("ours"));
534        assert_eq!(f.related[0].file.as_deref(), Some("a.py"));
535        assert_eq!(docs[0].bindings.renames[0].to, "get_user");
536    }
537
538    #[test]
539    fn a_delete_in_one_file_dangles_a_caller_in_another() {
540        let base = tree(&[
541            ("a.py", "def fetch_user(uid):\n    return uid\n"),
542            ("b.py", "def report():\n    return 1\n"),
543        ]);
544        let ours = tree(&[("a.py", "\n"), ("b.py", "def report():\n    return 1\n")]);
545        let theirs = tree(&[
546            ("a.py", "def fetch_user(uid):\n    return uid\n"),
547            ("b.py", "def report():\n    return fetch_user(1)\n"),
548        ]);
549        let docs = check(&base, &ours, &theirs);
550        let f = &docs[0].findings[0];
551        assert_eq!(f.class, "DANGLING");
552        assert!(f.suggestion.as_ref().unwrap().contains("deleted"));
553    }
554
555    #[test]
556    fn a_surviving_definition_elsewhere_is_not_dangling() {
557        // c.py still defines fetch_user, so the caller is bound.
558        let base = tree(&[
559            ("a.py", "def fetch_user(uid):\n    return uid\n"),
560            ("c.py", "def fetch_user(uid):\n    return uid\n"),
561            ("b.py", "def report():\n    return 1\n"),
562        ]);
563        let ours = tree(&[
564            ("a.py", "def get_user(uid):\n    return uid\n"),
565            ("c.py", "def fetch_user(uid):\n    return uid\n"),
566            ("b.py", "def report():\n    return 1\n"),
567        ]);
568        let theirs = tree(&[
569            ("a.py", "def fetch_user(uid):\n    return uid\n"),
570            ("c.py", "def fetch_user(uid):\n    return uid\n"),
571            ("b.py", "def report():\n    return fetch_user(1)\n"),
572        ]);
573        assert!(check(&base, &ours, &theirs).is_empty());
574    }
575
576    #[test]
577    fn an_untouched_caller_is_not_reported() {
578        // b.py references the old name but NEITHER side touched b.py: that is a
579        // pre-existing repo state, not something this merge composed.
580        let base = tree(&[
581            ("a.py", "def fetch_user(uid):\n    return uid\n"),
582            ("b.py", "def report():\n    return fetch_user(1)\n"),
583        ]);
584        let ours = tree(&[
585            ("a.py", "def get_user(uid):\n    return uid\n"),
586            ("b.py", "def report():\n    return fetch_user(1)\n"),
587        ]);
588        let theirs = base.clone();
589        assert!(check(&base, &ours, &theirs).is_empty());
590    }
591
592    #[test]
593    fn attribute_access_and_bare_mentions_do_not_count_as_calls() {
594        let base = tree(&[
595            ("a.py", "def fetch_user(uid):\n    return uid\n"),
596            ("b.py", "def report():\n    return 1\n"),
597        ]);
598        let ours = tree(&[
599            ("a.py", "def get_user(uid):\n    return uid\n"),
600            ("b.py", "def report():\n    return 1\n"),
601        ]);
602        let theirs = tree(&[
603            ("a.py", "def fetch_user(uid):\n    return uid\n"),
604            (
605                "b.py",
606                "def report():\n    log('fetch_user')\n    return db.fetch_user(1)\n",
607            ),
608        ]);
609        assert!(check(&base, &ours, &theirs).is_empty());
610    }
611
612    #[test]
613    fn a_new_definition_capturing_another_sides_call_is_shadow() {
614        let base = tree(&[
615            ("a.py", "def existing():\n    return 0\n"),
616            ("b.py", "def report():\n    return 1\n"),
617        ]);
618        // ours defines a brand-new `render`
619        let ours = tree(&[
620            (
621                "a.py",
622                "def existing():\n    return 0\n\n\ndef render(x):\n    return x\n",
623            ),
624            ("b.py", "def report():\n    return 1\n"),
625        ]);
626        // theirs adds code that calls `render` — meaning something imported
627        let theirs = tree(&[
628            ("a.py", "def existing():\n    return 0\n"),
629            ("b.py", "def report():\n    return render(1)\n"),
630        ]);
631        let docs = check(&base, &ours, &theirs);
632        assert_eq!(docs.len(), 1);
633        assert_eq!(docs[0].findings[0].class, "SHADOW");
634        assert_eq!(docs[0].findings[0].entity, "render");
635    }
636
637    #[test]
638    fn unsupported_files_are_skipped_rather_than_guessed_at() {
639        let base = tree(&[("a.bin", "def fetch_user(uid): pass\n")]);
640        let ours = tree(&[("a.bin", "def get_user(uid): pass\n")]);
641        let theirs = tree(&[("a.bin", "def fetch_user(uid): pass\nfetch_user(1)\n")]);
642        assert!(check(&base, &ours, &theirs).is_empty());
643    }
644
645    #[test]
646    fn documents_conform_to_the_schemas_required_keys() {
647        let base = tree(&[
648            ("a.py", "def fetch_user(uid):\n    return uid\n"),
649            ("b.py", "def report():\n    return 1\n"),
650        ]);
651        let ours = tree(&[
652            ("a.py", "def get_user(uid):\n    return uid\n"),
653            ("b.py", "def report():\n    return 1\n"),
654        ]);
655        let theirs = tree(&[
656            ("a.py", "def fetch_user(uid):\n    return uid\n"),
657            ("b.py", "def report():\n    return fetch_user(1)\n"),
658        ]);
659        let docs = check(&base, &ours, &theirs);
660        let v = serde_json::to_value(&docs[0]).unwrap();
661        for key in [
662            "schema_version",
663            "file",
664            "clean",
665            "confidence",
666            "findings",
667            "ops",
668            "bindings",
669        ] {
670            assert!(v.get(key).is_some(), "missing required key {key}");
671        }
672        assert_eq!(v["scope"], "repo");
673        assert_eq!(v["schema_version"], "1.1.0");
674    }
675}