Skip to main content

fallow_graph/graph/
partition_order.rs

1//! Partition + order engine: from a changed-file set, split the change into
2//! coherent, independently-reviewable UNITS and suggest a dependency-sensible
3//! review ORDER.
4//!
5//! v1 partitioning is BY-MODULE only (the load-bearing panel decision): a "unit"
6//! is the parent directory of a changed file, root-relative. This is the only
7//! clustering definition that is byte-identical-deterministic straight from the
8//! graph with zero heuristics; feature-cluster and concern partitioning are
9//! explicitly DEFERRED (they need scoring heuristics whose tie-breaks are a fresh
10//! nondeterminism + false-positive surface).
11//!
12//! The ORDER is a dependency-sensible topological sequence over the unit DAG: a
13//! unit that DEFINES what another CONSUMES comes first (review the load-bearing
14//! definition before its consumers), mechanical/leaf units last, ties broken by
15//! the path sort. Inter-unit edges come from the graph's forward edges
16//! ([`super::ModuleGraph::edges_for`], `mod.rs` L255; the inverse is
17//! `reverse_deps`, L75).
18//!
19//! Determinism (the roadmap done-condition "Same PR run twice -> byte-identical
20//! unit assignment and order"): the engine is a pure function of
21//! `(graph, changed_file_ids)`. No timestamps, no randomness. No `FxHashMap`
22//! iteration order ever reaches output; every collection is materialized into a
23//! `Vec` and explicitly sorted before use. FileIds are path-sorted and stable
24//! cross-run (ADR-004), so sorting by FileId == sorting by path. The only choice
25//! point in the topological sort is a min-pick over sorted `module_dir` strings.
26
27use std::path::{Path, PathBuf};
28
29use fallow_types::discover::FileId;
30use rustc_hash::{FxHashMap, FxHashSet};
31
32use super::ModuleGraph;
33
34/// A single review unit: a coherent by-module cluster of the changed set. The
35/// `module_dir` is the root-relative parent directory shared by `files`; the
36/// changed root file (one with no parent directory) clusters under the
37/// repository-root key (the empty string).
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ReviewUnit {
40    /// The module directory the unit covers (root-relative, forward-slashed).
41    /// The empty string is the repository-root group for changed files with no
42    /// parent directory.
43    module_dir: String,
44    /// The changed files in this unit, `FileId`-sorted (== path-sorted, ADR-004).
45    files: Vec<FileId>,
46}
47
48/// Result of a partition + order computation, keyed by `FileId` / `module_dir`.
49/// The caller relativizes via [`ModuleGraph::partition_order_with_paths`] for
50/// serialization, mirroring the `impact_closure` / `closure_with_paths` pair.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct PartitionOrder {
53    /// The by-module units, sorted by `module_dir` string.
54    units: Vec<ReviewUnit>,
55    /// The dependency-sensible review order: `module_dir` strings, definitions
56    /// before consumers, mechanical/leaf units last, ties broken by the path
57    /// sort. One entry per unit; a permutation of the `units` `module_dir` set.
58    order: Vec<String>,
59    /// Connected components of the inter-unit dependency graph: groups of units
60    /// that share no import edge with any unit outside their group. Each slice
61    /// is `module_dir`-sorted; slices are sorted by their first entry. Two or
62    /// more slices mean the change has no import edge across that seam; whether
63    /// the pieces can land separately is still the reviewer's call.
64    independent_slices: Vec<Vec<String>>,
65}
66
67/// The same partition + order with each unit's `FileId`s resolved to
68/// root-relative, forward-slashed path strings, sorted for deterministic output.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct PartitionOrderPaths {
71    /// The by-module units with file paths resolved, sorted by `module_dir`.
72    pub units: Vec<ReviewUnitPaths>,
73    /// The dependency-sensible review order of `module_dir` strings.
74    pub order: Vec<String>,
75    /// Connected components of the inter-unit dependency graph, each a sorted
76    /// list of `module_dir` strings, sorted by first entry.
77    pub independent_slices: Vec<Vec<String>>,
78}
79
80/// A [`ReviewUnit`] with `FileId`s resolved to root-relative paths.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ReviewUnitPaths {
83    /// The module directory the unit covers (root-relative, forward-slashed).
84    pub module_dir: String,
85    /// The changed files in this unit, path-sorted.
86    pub files: Vec<String>,
87}
88
89impl ModuleGraph {
90    /// Compute the by-module partition and dependency-sensible order for a
91    /// changed-file seed set.
92    ///
93    /// Out-of-range or duplicate ids in `changed` are tolerated (dropped /
94    /// deduped). The partition groups each changed file by its parent directory;
95    /// the order is a deterministic topological sort over the inter-unit DAG
96    /// (definitions before consumers, ties broken by the `module_dir` sort).
97    #[must_use]
98    pub fn partition_order(&self, changed: &[FileId]) -> PartitionOrder {
99        // Dedup + drop out-of-range ids, keeping a path-stable working set.
100        let mut seen = FxHashSet::default();
101        let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
102        for &id in changed {
103            if (id.0 as usize) < self.modules.len() && seen.insert(id) {
104                changed_ids.push(id);
105            }
106        }
107        changed_ids.sort_unstable_by_key(|f| f.0);
108
109        let units = self.build_units(&changed_ids);
110        let deps = self.unit_deps(&units, &changed_ids);
111        let order = if units.is_empty() {
112            Vec::new()
113        } else {
114            kahn_min_pick(&units, &deps)
115        };
116        let independent_slices = independent_slices(&units, &deps);
117        PartitionOrder {
118            units,
119            order,
120            independent_slices,
121        }
122    }
123
124    /// Group changed files by their parent directory (the module). Returns the
125    /// units sorted by `module_dir`, each unit's files `FileId`-sorted.
126    fn build_units(&self, changed_ids: &[FileId]) -> Vec<ReviewUnit> {
127        // module_dir -> files. FxHashMap iteration order never reaches output:
128        // the keys are pulled into a Vec and sorted below.
129        let mut by_dir: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
130        for &id in changed_ids {
131            let Some(module) = self.modules.get(id.0 as usize) else {
132                continue;
133            };
134            let dir = module_dir_key(&module.path);
135            by_dir.entry(dir).or_default().push(id);
136        }
137
138        let mut units: Vec<ReviewUnit> = by_dir
139            .into_iter()
140            .map(|(module_dir, mut files)| {
141                files.sort_unstable_by_key(|f| f.0);
142                ReviewUnit { module_dir, files }
143            })
144            .collect();
145        units.sort_by(|a, b| a.module_dir.cmp(&b.module_dir));
146        units
147    }
148
149    /// Resolve the inter-unit dependency sets: `deps[c]` holds the indices of the
150    /// units `c` consumes from (its own unit excluded). Shared by the Kahn order
151    /// (definitions before consumers) and the independent-slice components.
152    fn unit_deps(&self, units: &[ReviewUnit], changed_ids: &[FileId]) -> Vec<FxHashSet<usize>> {
153        // FileId -> owning unit index, for resolving inter-unit edges.
154        let unit_of: FxHashMap<FileId, usize> = units
155            .iter()
156            .enumerate()
157            .flat_map(|(i, unit)| unit.files.iter().map(move |&f| (f, i)))
158            .collect();
159
160        let unit_count = units.len();
161        // `dep_count[c]` = number of distinct units `c` depends on (consumes from)
162        // that are still unemitted. A unit emerges ready once all its deps are
163        // emitted, so a pure definition (depends on nothing in the changed set)
164        // is ready first.
165        let mut deps: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); unit_count];
166        for &id in changed_ids {
167            let Some(&consumer_unit) = unit_of.get(&id) else {
168                continue;
169            };
170            for dep_target in self.edges_for(id) {
171                let Some(&dep_unit) = unit_of.get(&dep_target) else {
172                    continue;
173                };
174                if dep_unit != consumer_unit {
175                    deps[consumer_unit].insert(dep_unit);
176                }
177            }
178        }
179        deps
180    }
181
182    /// Resolve a partition + order's `FileId`s to root-relative, forward-slashed
183    /// paths, sorted for deterministic output. Files whose module is missing are
184    /// dropped; a unit left empty after that drop is omitted. The `module_dir`
185    /// keys (and the `order` entries, which are `module_dir` strings) are
186    /// root-relativized too so the whole shape is root-relative.
187    #[must_use]
188    pub fn partition_order_with_paths(
189        &self,
190        partition: &PartitionOrder,
191        root: &Path,
192    ) -> PartitionOrderPaths {
193        let resolve = |id: FileId| -> Option<String> {
194            self.modules
195                .get(id.0 as usize)
196                .map(|m| relativize(&m.path, root))
197        };
198
199        let units: Vec<ReviewUnitPaths> = partition
200            .units
201            .iter()
202            .filter_map(|unit| {
203                let mut files: Vec<String> =
204                    unit.files.iter().filter_map(|&id| resolve(id)).collect();
205                if files.is_empty() {
206                    return None;
207                }
208                files.sort();
209                Some(ReviewUnitPaths {
210                    module_dir: relativize_dir(&unit.module_dir, root),
211                    files,
212                })
213            })
214            .collect();
215
216        let order: Vec<String> = partition
217            .order
218            .iter()
219            .map(|dir| relativize_dir(dir, root))
220            .collect();
221        let mut independent_slices: Vec<Vec<String>> = partition
222            .independent_slices
223            .iter()
224            .map(|slice| {
225                let mut dirs: Vec<String> =
226                    slice.iter().map(|dir| relativize_dir(dir, root)).collect();
227                dirs.sort();
228                dirs
229            })
230            .collect();
231        independent_slices.sort();
232
233        PartitionOrderPaths {
234            units,
235            order,
236            independent_slices,
237        }
238    }
239}
240
241/// Connected components over the UNDIRECTED inter-unit dependency graph. A
242/// component is a set of units reachable from each other through import edges
243/// in either direction; units in different components never touch. Each slice
244/// is `module_dir`-sorted and the slices are sorted by first entry, so the
245/// output is a pure function of `(units, deps)`.
246fn independent_slices(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<Vec<String>> {
247    let unit_count = units.len();
248    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); unit_count];
249    for (consumer, targets) in deps.iter().enumerate() {
250        for &target in targets {
251            adjacency[consumer].push(target);
252            adjacency[target].push(consumer);
253        }
254    }
255
256    let mut component_of: Vec<Option<usize>> = vec![None; unit_count];
257    let mut slices: Vec<Vec<String>> = Vec::new();
258    for start in 0..unit_count {
259        if component_of[start].is_some() {
260            continue;
261        }
262        let component = slices.len();
263        let mut stack = vec![start];
264        let mut members: Vec<String> = Vec::new();
265        while let Some(idx) = stack.pop() {
266            if component_of[idx].is_some() {
267                continue;
268            }
269            component_of[idx] = Some(component);
270            members.push(units[idx].module_dir.clone());
271            stack.extend(adjacency[idx].iter().copied());
272        }
273        members.sort();
274        slices.push(members);
275    }
276    slices.sort();
277    slices
278}
279
280/// Deterministic Kahn topological sort: emit a unit only once every unit it
281/// depends on (consumes from) has been emitted, so definitions precede
282/// consumers. The ready set is resolved by a min-pick over the `module_dir`
283/// strings, so ties (independent units) and any cycle break resolve by the path
284/// sort. A residual cycle's units are appended in `module_dir`-sorted order.
285fn kahn_min_pick(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<String> {
286    let unit_count = units.len();
287    let mut remaining: FxHashSet<usize> = (0..unit_count).collect();
288    let mut emitted: FxHashSet<usize> = FxHashSet::default();
289    let mut order: Vec<String> = Vec::with_capacity(unit_count);
290
291    while !remaining.is_empty() {
292        // Find the lexicographically smallest module_dir among ready units (all
293        // deps emitted). Iterating `remaining` (an FxHashSet) is fine: we pick
294        // the min by module_dir, not by iteration order.
295        let mut ready: Option<usize> = None;
296        for &idx in &remaining {
297            let all_deps_emitted = deps[idx].iter().all(|d| emitted.contains(d));
298            if !all_deps_emitted {
299                continue;
300            }
301            ready = Some(match ready {
302                Some(cur) if units[cur].module_dir <= units[idx].module_dir => cur,
303                _ => idx,
304            });
305        }
306
307        match ready {
308            Some(idx) => {
309                order.push(units[idx].module_dir.clone());
310                emitted.insert(idx);
311                remaining.remove(&idx);
312            }
313            None => {
314                // Cycle: no ready unit but units remain. Append the rest in
315                // module_dir-sorted order (deterministic fallback).
316                let mut rest: Vec<usize> = remaining.iter().copied().collect();
317                rest.sort_by(|&a, &b| units[a].module_dir.cmp(&units[b].module_dir));
318                for idx in rest {
319                    order.push(units[idx].module_dir.clone());
320                }
321                break;
322            }
323        }
324    }
325
326    order
327}
328
329/// The root-relative parent-directory key for a module path. The repository-root
330/// file (no parent component) maps to the empty string (the root group).
331fn module_dir_key(path: &Path) -> String {
332    path.parent()
333        .map(|p| p.to_string_lossy().replace('\\', "/"))
334        .unwrap_or_default()
335}
336
337/// Strip `root` and forward-slash-normalize a module path for cross-platform
338/// JSON parity (mirrors `impact_closure::relativize`).
339fn relativize(path: &Path, root: &Path) -> String {
340    let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
341    rel.to_string_lossy().replace('\\', "/")
342}
343
344/// Root-relativize a `module_dir` key (a forward-slashed directory string).
345/// The empty root-group key stays empty; otherwise the `root` prefix is stripped
346/// via the same `Path`-based logic so the output matches the file path-space.
347fn relativize_dir(dir: &str, root: &Path) -> String {
348    if dir.is_empty() {
349        return String::new();
350    }
351    relativize(Path::new(dir), root)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
358    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
359    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
360    use std::path::PathBuf;
361
362    fn file(id: u32, path: &str) -> DiscoveredFile {
363        DiscoveredFile {
364            id: FileId(id),
365            path: PathBuf::from(path),
366            size_bytes: 10,
367        }
368    }
369
370    fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
371        ResolvedImport {
372            info: ImportInfo {
373                source: source.to_string(),
374                imported_name: ImportedName::Named(name.to_string()),
375                local_name: name.to_string(),
376                is_type_only: false,
377                is_type_only_star: false,
378                from_style: false,
379                span: oxc_span::Span::new(0, 10),
380                source_span: oxc_span::Span::default(),
381            },
382            target: ResolveResult::InternalModule(target),
383        }
384    }
385
386    fn named_export(name: &str) -> ExportInfo {
387        ExportInfo {
388            name: ExportName::Named(name.to_string()),
389            local_name: Some(name.to_string()),
390            is_type_only: false,
391            visibility: VisibilityTag::None,
392            expected_unused_reason: None,
393            span: oxc_span::Span::new(0, 20),
394            members: vec![],
395            is_side_effect_used: false,
396            super_class: None,
397        }
398    }
399
400    /// Three directories: `core/` defines, `mid/` consumes core, `app/` consumes
401    /// mid. Files: core/a.ts, core/b.ts, mid/m.ts, app/x.ts. entry is app/x.ts.
402    fn build_three_dir_graph() -> ModuleGraph {
403        let files = vec![
404            file(0, "/p/src/app/x.ts"),
405            file(1, "/p/src/core/a.ts"),
406            file(2, "/p/src/core/b.ts"),
407            file(3, "/p/src/mid/m.ts"),
408        ];
409        let entry_points = vec![EntryPoint {
410            path: PathBuf::from("/p/src/app/x.ts"),
411            source: EntryPointSource::PackageJsonMain,
412        }];
413        let resolved = vec![
414            ResolvedModule {
415                file_id: FileId(0),
416                path: PathBuf::from("/p/src/app/x.ts"),
417                resolved_imports: vec![named_import("../mid/m", "midFn", FileId(3))],
418                ..Default::default()
419            },
420            ResolvedModule {
421                file_id: FileId(1),
422                path: PathBuf::from("/p/src/core/a.ts"),
423                exports: vec![named_export("alpha")].into(),
424                ..Default::default()
425            },
426            ResolvedModule {
427                file_id: FileId(2),
428                path: PathBuf::from("/p/src/core/b.ts"),
429                exports: vec![named_export("beta")].into(),
430                ..Default::default()
431            },
432            ResolvedModule {
433                file_id: FileId(3),
434                path: PathBuf::from("/p/src/mid/m.ts"),
435                resolved_imports: vec![named_import("../core/a", "alpha", FileId(1))],
436                exports: vec![named_export("midFn")].into(),
437                ..Default::default()
438            },
439        ];
440        ModuleGraph::build(&resolved, &entry_points, &files)
441    }
442
443    #[test]
444    fn partition_groups_changed_files_by_module_directory() {
445        let graph = build_three_dir_graph();
446        // Change all four files.
447        let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
448        let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
449        // Three units, sorted by module_dir.
450        let dirs: Vec<&str> = paths.units.iter().map(|u| u.module_dir.as_str()).collect();
451        assert_eq!(dirs, vec!["src/app", "src/core", "src/mid"]);
452        // core/ groups its two files, path-sorted.
453        let core = paths
454            .units
455            .iter()
456            .find(|u| u.module_dir == "src/core")
457            .expect("core unit");
458        assert_eq!(core.files, vec!["src/core/a.ts", "src/core/b.ts"]);
459    }
460
461    #[test]
462    fn order_places_definitions_before_consumers() {
463        let graph = build_three_dir_graph();
464        // app consumes mid consumes core, so order = core, mid, app.
465        let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
466        assert_eq!(
467            partition.order,
468            vec![
469                "/p/src/core".to_string(),
470                "/p/src/mid".to_string(),
471                "/p/src/app".to_string(),
472            ]
473        );
474    }
475
476    #[test]
477    fn independent_units_order_by_path_sort() {
478        // Two unrelated directories (no inter-unit edge): order is the path sort.
479        let files = vec![file(0, "/p/src/billing/b.ts"), file(1, "/p/src/auth/a.ts")];
480        let entry_points = vec![EntryPoint {
481            path: PathBuf::from("/p/src/auth/a.ts"),
482            source: EntryPointSource::PackageJsonMain,
483        }];
484        let resolved = vec![
485            ResolvedModule {
486                file_id: FileId(0),
487                path: PathBuf::from("/p/src/billing/b.ts"),
488                ..Default::default()
489            },
490            ResolvedModule {
491                file_id: FileId(1),
492                path: PathBuf::from("/p/src/auth/a.ts"),
493                ..Default::default()
494            },
495        ];
496        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
497        let partition = graph.partition_order(&[FileId(0), FileId(1)]);
498        assert_eq!(
499            partition.order,
500            vec!["/p/src/auth".to_string(), "/p/src/billing".to_string()]
501        );
502        assert_eq!(
503            partition.independent_slices,
504            vec![
505                vec!["/p/src/auth".to_string()],
506                vec!["/p/src/billing".to_string()]
507            ],
508            "no inter-unit edge: each unit is its own slice"
509        );
510    }
511
512    #[test]
513    fn connected_units_collapse_into_one_slice() {
514        // core <- mid <- app is one connected component whichever direction the
515        // edges point, so a chain never splits.
516        let graph = build_three_dir_graph();
517        let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
518        assert_eq!(
519            partition.independent_slices,
520            vec![vec![
521                "/p/src/app".to_string(),
522                "/p/src/core".to_string(),
523                "/p/src/mid".to_string()
524            ]]
525        );
526        let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
527        assert_eq!(
528            paths.independent_slices,
529            vec![vec![
530                "src/app".to_string(),
531                "src/core".to_string(),
532                "src/mid".to_string()
533            ]]
534        );
535    }
536
537    #[test]
538    fn partition_order_is_byte_identical_across_runs() {
539        let graph = build_three_dir_graph();
540        let changed = [FileId(0), FileId(1), FileId(2), FileId(3)];
541        let first = graph.partition_order(&changed);
542        let second = graph.partition_order(&changed);
543        // FileId-keyed shape is structurally identical.
544        assert_eq!(first, second);
545        // Path-resolved shape is byte-identical when debug-rendered (a proxy for
546        // serialization; the audit_brief layer serializes the same data).
547        let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
548        let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
549        assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
550    }
551
552    #[test]
553    fn changed_set_order_does_not_affect_result() {
554        // Feeding the changed ids in a different input order yields the same
555        // partition + order (the engine sorts internally).
556        let graph = build_three_dir_graph();
557        let a = graph.partition_order(&[FileId(3), FileId(0), FileId(2), FileId(1)]);
558        let b = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
559        assert_eq!(a, b);
560    }
561
562    #[test]
563    fn root_file_clusters_under_root_group() {
564        let files = vec![file(0, "index.ts")];
565        let entry_points = vec![EntryPoint {
566            path: PathBuf::from("index.ts"),
567            source: EntryPointSource::PackageJsonMain,
568        }];
569        let resolved = vec![ResolvedModule {
570            file_id: FileId(0),
571            path: PathBuf::from("index.ts"),
572            ..Default::default()
573        }];
574        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
575        let partition = graph.partition_order(&[FileId(0)]);
576        assert_eq!(partition.units.len(), 1);
577        assert_eq!(partition.units[0].module_dir, "");
578    }
579
580    #[test]
581    fn empty_changed_set_yields_empty_partition() {
582        let graph = build_three_dir_graph();
583        let partition = graph.partition_order(&[]);
584        assert!(partition.units.is_empty());
585        assert!(partition.order.is_empty());
586    }
587
588    #[test]
589    fn scale_300_file_multi_module_graph_is_stable() {
590        // 30 directories x 10 files = 300 files. Each dir's first file imports the
591        // previous dir's first file (a chain across modules), so the order is a
592        // real topological sequence, not just the path sort.
593        const DIRS: u32 = 30;
594        const PER_DIR: u32 = 10;
595        let mut files = Vec::new();
596        let mut resolved = Vec::new();
597        for d in 0..DIRS {
598            for f in 0..PER_DIR {
599                let id = d * PER_DIR + f;
600                let path = format!("/p/src/mod{d:02}/file{f:02}.ts");
601                files.push(file(id, &path));
602            }
603        }
604        for d in 0..DIRS {
605            for f in 0..PER_DIR {
606                let id = d * PER_DIR + f;
607                let mut module = ResolvedModule {
608                    file_id: FileId(id),
609                    path: PathBuf::from(format!("/p/src/mod{d:02}/file{f:02}.ts")),
610                    exports: vec![named_export(&format!("e{id}"))].into(),
611                    ..Default::default()
612                };
613                // The first file of each dir (except dir 0) imports the first file
614                // of the previous dir: mod01 consumes mod00, mod02 consumes mod01.
615                if f == 0 && d > 0 {
616                    let dep = (d - 1) * PER_DIR;
617                    module.resolved_imports =
618                        vec![named_import("../prev", &format!("e{dep}"), FileId(dep))];
619                }
620                resolved.push(module);
621            }
622        }
623        let entry_points = vec![EntryPoint {
624            path: PathBuf::from("/p/src/mod00/file00.ts"),
625            source: EntryPointSource::PackageJsonMain,
626        }];
627        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
628
629        let changed: Vec<FileId> = (0..DIRS * PER_DIR).map(FileId).collect();
630        let first = graph.partition_order(&changed);
631        let second = graph.partition_order(&changed);
632        assert_eq!(first, second, "300-file partition must be stable");
633        assert_eq!(first.units.len(), DIRS as usize, "one unit per directory");
634        // The dependency chain forces mod00 before mod01 before ... before mod29.
635        let expected: Vec<String> = (0..DIRS).map(|d| format!("/p/src/mod{d:02}")).collect();
636        assert_eq!(
637            first.order, expected,
638            "definitions precede consumers at scale"
639        );
640        // Path-resolved serialization proxy is byte-identical across runs.
641        let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
642        let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
643        assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
644    }
645}