1use 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
22const DEPENDENCY_SECTIONS: [&str; 4] = [
25 "dependencies",
26 "devDependencies",
27 "optionalDependencies",
28 "peerDependencies",
29];
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DependencyChange {
34 pub name: String,
36 pub section: String,
38 pub from: Option<String>,
40 pub to: String,
42 pub line: u32,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct ManifestDependencyDeltas {
49 pub added: Vec<DependencyChange>,
51 pub major_bumped: Vec<DependencyChange>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ManifestPair {
58 pub manifest: String,
60 pub base: Option<String>,
62 pub head: String,
64}
65
66#[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#[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#[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#[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#[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
179pub 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 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
236fn 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
259fn 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
282fn 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}