Skip to main content

fallow_api/
dependency_deltas.rs

1//! Dependency deltas for the decision surface: what a changed `package.json`
2//! adds or moves across a major version, read from the base and head manifest
3//! text, projected onto [`DependencyAnchor`]s. Pure over its inputs; the CLI
4//! reads base text through git and the typed runtime through the base
5//! worktree, and both hand the pairs to [`dependency_anchors_from_manifests`]
6//! so the two routes cannot drift.
7//!
8//! Firing precision mirrors the public-API rule R1: a manifest yields at most
9//! one "added" candidate set and one "major bump" candidate set, never one
10//! decision per package. Minor and patch bumps are not candidates; a range
11//! that does not start with a numeric version (a workspace, catalog, file,
12//! git, or tag specifier) is skipped rather than guessed at.
13
14use fallow_engine::module_graph::PackageImporters;
15use fallow_output::ReviewDeltas;
16use fallow_types::discover::FileId;
17use rustc_hash::{FxHashMap, FxHashSet};
18use serde_json::Value;
19
20use crate::decision_surface::{DependencyAnchor, DependencyChangeKind, DependencyEntry};
21
22/// The `package.json` sections a dependency entry can live in, in the
23/// precedence order a duplicate name resolves to.
24const DEPENDENCY_SECTIONS: [&str; 4] = [
25    "dependencies",
26    "devDependencies",
27    "optionalDependencies",
28    "peerDependencies",
29];
30
31/// One dependency entry that changed between base and head.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DependencyChange {
34    /// The package name.
35    pub name: String,
36    /// The manifest section the entry lives in at head.
37    pub section: String,
38    /// The base range, `None` when the entry is new.
39    pub from: Option<String>,
40    /// The head range.
41    pub to: String,
42    /// 1-based line of the entry in the head manifest, `0` when not found.
43    pub line: u32,
44}
45
46/// The dependency changes in one manifest, split by candidate kind.
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct ManifestDependencyDeltas {
49    /// Entries absent at base.
50    pub added: Vec<DependencyChange>,
51    /// Entries whose range moved across a major version (or a `0.x` minor).
52    pub major_bumped: Vec<DependencyChange>,
53}
54
55/// A changed manifest with its base and head text.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ManifestPair {
58    /// Root-relative, forward-slashed path of the manifest.
59    pub manifest: String,
60    /// The base text; `None` when the manifest did not exist at base.
61    pub base: Option<String>,
62    /// The head text.
63    pub head: String,
64}
65
66/// Whether `rel_path` is a `package.json` manifest fallow should diff.
67#[must_use]
68pub fn is_manifest_path(rel_path: &str) -> bool {
69    let name = rel_path.rsplit('/').next().unwrap_or(rel_path);
70    name == "package.json" && !rel_path.contains("node_modules/")
71}
72
73/// Diff the dependency sections of a manifest between `base` and `head` text.
74/// Malformed JSON on either side reads as empty: an unreadable base makes every
75/// head entry new, an unreadable head yields nothing.
76#[must_use]
77pub fn manifest_dependency_deltas(base: &str, head: &str) -> ManifestDependencyDeltas {
78    let base_entries = dependency_entries(base);
79    let head_entries = dependency_entries(head);
80    if head_entries.is_empty() {
81        return ManifestDependencyDeltas::default();
82    }
83
84    let mut deltas = ManifestDependencyDeltas::default();
85    let mut names: Vec<&String> = head_entries.keys().collect();
86    names.sort();
87    for name in names {
88        let (section, to) = &head_entries[name];
89        let line = entry_line(head, section, name);
90        let change = |from: Option<String>| DependencyChange {
91            name: name.clone(),
92            section: section.clone(),
93            from,
94            to: to.clone(),
95            line,
96        };
97        match base_entries.get(name) {
98            None => deltas.added.push(change(None)),
99            Some((_, from)) if is_major_bump(from, to) => {
100                deltas.major_bumped.push(change(Some(from.clone())));
101            }
102            Some(_) => {}
103        }
104    }
105    deltas
106}
107
108/// Whether moving a range from `from` to `to` crosses a major version. Both
109/// sides must start with a numeric version once the range operator is
110/// stripped; anything else (workspace, catalog, file, git, tag, wildcard) is
111/// not a bump fallow can classify. A `0.x` line treats a minor move as major,
112/// the semver convention for pre-1.0 packages.
113#[must_use]
114pub fn is_major_bump(from: &str, to: &str) -> bool {
115    let (Some((from_major, from_minor)), Some((to_major, to_minor))) =
116        (leading_version(from), leading_version(to))
117    else {
118        return false;
119    };
120    if from_major != to_major {
121        return true;
122    }
123    from_major == 0 && from_minor != to_minor
124}
125
126/// Project manifest pairs onto dependency anchors, one per manifest per kind,
127/// in manifest order. Importer counts come from the graph's package usage; a
128/// package the graph never saw counts zero.
129#[must_use]
130#[allow(
131    clippy::implicit_hasher,
132    reason = "fallow standardizes on FxHashMap; the importer map is always built by the engine with the fallow hasher"
133)]
134pub fn dependency_anchors_from_manifests(
135    manifests: &[ManifestPair],
136    package_importers: Option<&FxHashMap<String, PackageImporters>>,
137) -> Vec<DependencyAnchor> {
138    let mut anchors = Vec::new();
139    for pair in manifests {
140        let deltas = manifest_dependency_deltas(pair.base.as_deref().unwrap_or(""), &pair.head);
141        if !deltas.added.is_empty() {
142            anchors.push(to_anchor(
143                &pair.manifest,
144                DependencyChangeKind::Added,
145                &deltas.added,
146                package_importers,
147            ));
148        }
149        if !deltas.major_bumped.is_empty() {
150            anchors.push(to_anchor(
151                &pair.manifest,
152                DependencyChangeKind::MajorBump,
153                &deltas.major_bumped,
154                package_importers,
155            ));
156        }
157    }
158    anchors
159}
160
161/// The stable delta key for one dependency entry: `<manifest>::<name>` for an
162/// added entry, `<manifest>::<name>@<from>-><to>` for a bump. The decision
163/// candidate key joins these with `|`, so the brief's `deltas` and the
164/// decision's `signal_key` name the same change.
165#[must_use]
166pub fn dependency_delta_key(
167    manifest: &str,
168    kind: DependencyChangeKind,
169    entry: &DependencyEntry,
170) -> String {
171    match (kind, &entry.from) {
172        (DependencyChangeKind::MajorBump, Some(from)) => {
173            format!("{manifest}::{}@{from}->{}", entry.name, entry.to)
174        }
175        _ => format!("{manifest}::{}", entry.name),
176    }
177}
178
179/// Mirror the dependency anchors onto the brief's `deltas` as stable keys so
180/// the JSON envelope names what changed even when the cap collapses the
181/// decision.
182pub fn fill_dependency_delta_keys(deltas: &mut ReviewDeltas, anchors: &[DependencyAnchor]) {
183    for anchor in anchors {
184        for entry in &anchor.entries {
185            let key = dependency_delta_key(&anchor.manifest, anchor.kind, entry);
186            match anchor.kind {
187                DependencyChangeKind::MajorBump => deltas.dependency_major_bumped.push(key),
188                DependencyChangeKind::Added => deltas.dependency_added.push(key),
189            }
190        }
191    }
192    deltas.dependency_added.sort();
193    deltas.dependency_major_bumped.sort();
194}
195
196fn to_anchor(
197    manifest: &str,
198    kind: DependencyChangeKind,
199    entries: &[DependencyChange],
200    package_importers: Option<&FxHashMap<String, PackageImporters>>,
201) -> DependencyAnchor {
202    // Union, not sum: a module importing two packages of the same batch is one
203    // importer, and the count is both displayed and used for ranking.
204    let mut importer_ids: FxHashSet<FileId> = FxHashSet::default();
205    let mut out_of_diff_ids: FxHashSet<FileId> = FxHashSet::default();
206    for entry in entries {
207        if let Some(counts) = package_importers.and_then(|map| map.get(&entry.name)) {
208            importer_ids.extend(counts.importers.iter().copied());
209            out_of_diff_ids.extend(counts.out_of_diff.iter().copied());
210        }
211    }
212    let importers = importer_ids.len() as u64;
213    let out_of_diff_importers = out_of_diff_ids.len() as u64;
214    DependencyAnchor {
215        manifest: manifest.to_string(),
216        kind,
217        entries: entries
218            .iter()
219            .map(|entry| DependencyEntry {
220                name: entry.name.clone(),
221                section: entry.section.clone(),
222                from: entry.from.clone(),
223                to: entry.to.clone(),
224            })
225            .collect(),
226        importers,
227        out_of_diff_importers,
228        line: entries
229            .iter()
230            .map(|entry| entry.line)
231            .find(|line| *line > 0)
232            .unwrap_or(0),
233    }
234}
235
236/// The `(major, minor)` pair a range starts with, after stripping a leading
237/// range operator. An `npm:<name>@<range>` alias is read at its range, since
238/// that is how a package ships two majors of one peer side by side. `None`
239/// when the range does not start with digits.
240fn leading_version(range: &str) -> Option<(u64, u64)> {
241    let range = range
242        .trim()
243        .strip_prefix("npm:")
244        .and_then(|alias| alias.rsplit_once('@').map(|(_, range)| range))
245        .unwrap_or(range);
246    let trimmed = range
247        .trim()
248        .trim_start_matches(['^', '~', '=', 'v', '>', '<', ' ']);
249    let core = trimmed.split([' ', '|']).next().unwrap_or(trimmed);
250    let mut parts = core.split('.');
251    let major = parts.next()?.parse::<u64>().ok()?;
252    let minor = parts
253        .next()
254        .and_then(|m| m.parse::<u64>().ok())
255        .unwrap_or(0);
256    Some((major, minor))
257}
258
259/// Every `name -> (section, range)` pair across the dependency sections. A
260/// later section never overrides an earlier one, so a package listed as both
261/// a peer and a runtime dependency keeps its `dependencies` range.
262fn dependency_entries(text: &str) -> FxHashMap<String, (String, String)> {
263    let mut entries: FxHashMap<String, (String, String)> = FxHashMap::default();
264    let Ok(Value::Object(manifest)) = serde_json::from_str::<Value>(text) else {
265        return entries;
266    };
267    for section in DEPENDENCY_SECTIONS {
268        let Some(Value::Object(deps)) = manifest.get(section) else {
269            continue;
270        };
271        for (name, range) in deps {
272            if let Value::String(range) = range {
273                entries
274                    .entry(name.clone())
275                    .or_insert_with(|| (section.to_string(), range.clone()));
276            }
277        }
278    }
279    entries
280}
281
282/// 1-based line of the `"<name>":` key inside the `"<section>":` block of
283/// `text`, `0` when absent. The scan starts at the section header so a
284/// package listed in two sections anchors on the section that won.
285fn entry_line(text: &str, section: &str, name: &str) -> u32 {
286    let is_key = |line: &str, key: &str| {
287        line.trim_start()
288            .strip_prefix(&format!("\"{key}\""))
289            .is_some_and(|rest| rest.trim_start().starts_with(':'))
290    };
291    let lines: Vec<&str> = text.lines().collect();
292    let start = lines
293        .iter()
294        .position(|line| is_key(line, section))
295        .unwrap_or(0);
296    lines[start..]
297        .iter()
298        .position(|line| is_key(line, name))
299        .map_or(0, |offset| {
300            u32::try_from(start + offset + 1).unwrap_or(u32::MAX)
301        })
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn change(
309        name: &str,
310        section: &str,
311        from: Option<&str>,
312        to: &str,
313        line: u32,
314    ) -> DependencyChange {
315        DependencyChange {
316            name: name.to_string(),
317            section: section.to_string(),
318            from: from.map(str::to_string),
319            to: to.to_string(),
320            line,
321        }
322    }
323
324    #[test]
325    fn added_and_major_bumped_entries_are_split_and_sorted() {
326        let base = r#"{ "dependencies": { "react": "^18.2.0", "zod": "^3.0.0" } }"#;
327        let head = "{\n  \"dependencies\": {\n    \"react\": \"^19.0.0\",\n    \"zod\": \"^3.4.0\",\n    \"dayjs\": \"^1.11.0\"\n  }\n}\n";
328        let deltas = manifest_dependency_deltas(base, head);
329        assert_eq!(
330            deltas.added,
331            vec![change("dayjs", "dependencies", None, "^1.11.0", 5)]
332        );
333        assert_eq!(
334            deltas.major_bumped,
335            vec![change(
336                "react",
337                "dependencies",
338                Some("^18.2.0"),
339                "^19.0.0",
340                3
341            )]
342        );
343    }
344
345    #[test]
346    fn non_numeric_ranges_and_minor_bumps_are_not_candidates() {
347        assert!(!is_major_bump("^1.2.0", "^1.9.0"));
348        assert!(!is_major_bump("workspace:*", "workspace:^"));
349        assert!(!is_major_bump("catalog:", "catalog:react19"));
350        assert!(!is_major_bump("^2.0.0", "file:../local"));
351        assert!(!is_major_bump("latest", "^3.0.0"));
352        assert!(is_major_bump("npm:foo@1.0.0", "npm:foo@2.0.0"));
353        assert!(is_major_bump(
354            "npm:@scope/pkg@^1.4.0",
355            "npm:@scope/pkg@^2.0.0"
356        ));
357        assert!(!is_major_bump("npm:foo@^1.0.0", "npm:foo@^1.9.0"));
358        assert!(!is_major_bump("1.0.0-beta.2", "1.0.0"));
359        assert!(is_major_bump("~1.4.0", "2.0.0"));
360        assert!(is_major_bump(">=1.0.0 <2.0.0", "^3.0.0"));
361        assert!(is_major_bump("1.x || 2.x", "3.0.0"));
362    }
363
364    #[test]
365    fn zero_x_minor_move_counts_as_major() {
366        assert!(is_major_bump("^0.3.1", "^0.4.0"));
367        assert!(!is_major_bump("^0.3.1", "^0.3.9"));
368    }
369
370    #[test]
371    fn dev_and_peer_sections_participate_without_overriding_runtime_ranges() {
372        let base = r#"{ "devDependencies": { "vitest": "^1.0.0" } }"#;
373        let head = r#"{ "dependencies": { "zod": "^3.0.0" }, "devDependencies": { "vitest": "^2.0.0", "zod": "^9.0.0" } }"#;
374        let deltas = manifest_dependency_deltas(base, head);
375        assert_eq!(deltas.major_bumped.len(), 1);
376        assert_eq!(deltas.major_bumped[0].name, "vitest");
377        assert_eq!(deltas.major_bumped[0].section, "devDependencies");
378        assert_eq!(deltas.added.len(), 1);
379        assert_eq!(deltas.added[0].to, "^3.0.0", "the runtime range wins");
380        assert_eq!(deltas.added[0].section, "dependencies");
381    }
382
383    #[test]
384    fn malformed_manifest_yields_nothing_or_all_new() {
385        let all_new = manifest_dependency_deltas("{", r#"{ "dependencies": { "a": "^1.0.0" } }"#);
386        assert_eq!(
387            all_new.added,
388            vec![change("a", "dependencies", None, "^1.0.0", 0)]
389        );
390        assert!(all_new.major_bumped.is_empty());
391        assert_eq!(
392            manifest_dependency_deltas(r#"{ "dependencies": { "a": "^1.0.0" } }"#, "{"),
393            ManifestDependencyDeltas::default()
394        );
395    }
396
397    #[test]
398    fn batched_importers_are_a_union_not_a_sum() {
399        let pairs = vec![ManifestPair {
400            manifest: "package.json".to_string(),
401            base: Some(
402                r#"{ "dependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }"#.to_string(),
403            ),
404            head: r#"{ "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }"#
405                .to_string(),
406        }];
407        let mut importers = FxHashMap::default();
408        for name in ["react", "react-dom"] {
409            importers.insert(
410                name.to_string(),
411                PackageImporters {
412                    importers: vec![FileId(1), FileId(2)],
413                    out_of_diff: vec![FileId(2)],
414                },
415            );
416        }
417        let anchors = dependency_anchors_from_manifests(&pairs, Some(&importers));
418        assert_eq!(anchors.len(), 1);
419        assert_eq!(anchors[0].entries.len(), 2);
420        assert_eq!(anchors[0].importers, 2, "two modules import both packages");
421        assert_eq!(anchors[0].out_of_diff_importers, 1);
422    }
423
424    #[test]
425    fn entry_line_follows_the_winning_section() {
426        let head = "{\n  \"devDependencies\": {\n    \"zod\": \"^9.0.0\"\n  },\n  \"dependencies\": {\n    \"zod\": \"^3.0.0\"\n  }\n}\n";
427        let deltas = manifest_dependency_deltas("{}", head);
428        assert_eq!(deltas.added.len(), 1);
429        assert_eq!(deltas.added[0].section, "dependencies");
430        assert_eq!(
431            deltas.added[0].line, 6,
432            "the runtime entry, not the dev one above it"
433        );
434    }
435
436    #[test]
437    fn manifest_paths_exclude_node_modules() {
438        assert!(is_manifest_path("package.json"));
439        assert!(is_manifest_path("packages/web/package.json"));
440        assert!(!is_manifest_path("node_modules/react/package.json"));
441        assert!(!is_manifest_path("packages/web/tsconfig.json"));
442    }
443
444    #[test]
445    fn anchors_and_delta_keys_agree_with_the_candidate_key() {
446        let pairs = vec![ManifestPair {
447            manifest: "packages/web/package.json".to_string(),
448            base: Some(r#"{ "dependencies": { "react": "^18.0.0" } }"#.to_string()),
449            head: r#"{ "dependencies": { "react": "^19.0.0", "dayjs": "^1.0.0" } }"#.to_string(),
450        }];
451        let ids = |range: std::ops::Range<u32>| range.map(FileId).collect::<Vec<_>>();
452        let mut importers = FxHashMap::default();
453        importers.insert(
454            "react".to_string(),
455            PackageImporters {
456                importers: ids(0..40),
457                out_of_diff: ids(2..40),
458            },
459        );
460        let anchors = dependency_anchors_from_manifests(&pairs, Some(&importers));
461        assert_eq!(anchors.len(), 2);
462        assert_eq!(anchors[0].kind, DependencyChangeKind::Added);
463        assert_eq!(anchors[0].importers, 0);
464        assert_eq!(anchors[1].kind, DependencyChangeKind::MajorBump);
465        assert_eq!(anchors[1].importers, 40);
466        assert_eq!(anchors[1].out_of_diff_importers, 38);
467
468        let mut deltas = ReviewDeltas::default();
469        fill_dependency_delta_keys(&mut deltas, &anchors);
470        assert_eq!(
471            deltas.dependency_added,
472            vec!["packages/web/package.json::dayjs"]
473        );
474        assert_eq!(
475            deltas.dependency_major_bumped,
476            vec!["packages/web/package.json::react@^18.0.0->^19.0.0"]
477        );
478        let surface = crate::decision_surface::extract_decision_surface(
479            &crate::decision_surface::DecisionInputs {
480                deltas: &deltas,
481                boundary_anchors: &[],
482                coordination: &[],
483                dependency_anchors: &anchors,
484                public_api_anchor_line: 0,
485                affected_not_shown: 0,
486                routing: &fallow_output::RoutingFacts::default(),
487                head_source: &|_: &str| None,
488                rename_old_path: &|_: &str| None,
489                internal_consumers: &|_: &str| 0,
490                cap: 4,
491            },
492        );
493        let keys: Vec<&str> = surface
494            .decisions
495            .iter()
496            .map(|d| d.signal_key.as_str())
497            .collect();
498        assert!(keys.contains(&"packages/web/package.json::react@^18.0.0->^19.0.0"));
499        assert!(keys.contains(&"packages/web/package.json::dayjs"));
500    }
501}