car_ast/footprint.rs
1//! Advisory symbol-footprint scheduler for the Foreman pattern (B4).
2//!
3//! **This is NOT a soundness mechanism.** Correctness is owned entirely by the
4//! merge-verify gate (see `car-multi`'s foreman gate). This module only schedules
5//! subtasks for *better parallelism* — it decides which farmed-out subtasks may
6//! run concurrently versus serially. Because it is only a hint, it is allowed to
7//! be wrong in the *conservative* direction (serialize things that could have
8//! run in parallel) but never in the dangerous direction.
9//!
10//! That bias is enforced by being **fail-closed on uncertainty** — but the
11//! *kind* of uncertainty determines how conservative we must be ([`Scheduling`]):
12//!
13//! - **[`Precise`](Scheduling::Precise)** — the blast radius fully resolved.
14//! Schedule on exact symbol-overlap.
15//! - **[`FileLevel`](Scheduling::FileLevel)** — a declared symbol isn't in the
16//! index *because it's new* (greenfield "create `foo`"). A symbol that does
17//! not exist yet has no existing callers, so there is no blast radius to miss;
18//! the declared write *files* are still trustworthy. Fall back to file-level
19//! disjointness — the **same basis as the no-footprint partitioner**, which
20//! the system already trusts. This inherits the partitioner's one assumption:
21//! the agent edits only the files it declared (callers are semantic impact,
22//! not files it touches). A worktree collision on an *undeclared* file, or any
23//! cross-file semantic break the lost blast radius would have flagged, is
24//! caught by the gate's containment + union merge — not the scheduler.
25//! - **[`Serialize`](Scheduling::Serialize)** — the unresolved blast radius
26//! cannot be safely downgraded. Under a truncated [`ProjectIndex`] an unknown
27//! symbol is *ambiguous*: it could be genuinely new (no callers) OR an existing
28//! symbol whose callers are merely **hidden** by the truncation — a real blast
29//! radius wrongly read as "no callers" (the Phase-0 fail-open finding). The
30//! declared file set is still known, but we cannot tell the two cases apart, so
31//! we collapse the ambiguity safely and conflict with everything. (A
32//! planner-declared uncertain footprint is likewise treated as `Serialize`.)
33//!
34//! Conflict model over footprints:
35//! - both `Precise`: `write(A) ∩ write(B) ≠ ∅` (symbol blast radius) → conflict.
36//! - either `FileLevel` (neither `Serialize`): declared write *files* overlap → conflict.
37//! - either `Serialize`: always conflict (serialized).
38//! - `write(A) ∩ read(B) ≠ ∅` (declared symbols) → **edge** A→B: B runs after A.
39//! - otherwise → **independent**: may run in parallel.
40
41use std::collections::{BTreeSet, HashMap, HashSet};
42
43use crate::index::ProjectIndex;
44
45/// A `(file, symbol)` location. `file` is repo-relative.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
47pub struct SymbolRef {
48 pub file: String,
49 pub symbol: String,
50}
51
52impl SymbolRef {
53 pub fn new(file: impl Into<String>, symbol: impl Into<String>) -> Self {
54 Self {
55 file: file.into(),
56 symbol: symbol.into(),
57 }
58 }
59}
60
61/// The symbols a subtask is expected to write (define/modify) and read.
62#[derive(Debug, Clone, Default)]
63pub struct SymbolFootprint {
64 pub writes: HashSet<SymbolRef>,
65 pub reads: HashSet<SymbolRef>,
66 /// On a *declared* footprint: an input the planner sets when it could not
67 /// confidently determine the footprint → expands to [`Scheduling::Serialize`].
68 /// On an *expanded* footprint: a derived inspection view, `true` whenever the
69 /// blast radius wasn't fully resolved (i.e. `Scheduling::FileLevel` or
70 /// `Serialize`). The scheduler branches on [`Scheduling`], **never** on this —
71 /// `FileLevel` and `Serialize` both report `uncertain == true` but schedule
72 /// very differently.
73 pub uncertain: bool,
74}
75
76impl SymbolFootprint {
77 pub fn writing(writes: impl IntoIterator<Item = SymbolRef>) -> Self {
78 Self {
79 writes: writes.into_iter().collect(),
80 ..Default::default()
81 }
82 }
83
84 pub fn with_reads(mut self, reads: impl IntoIterator<Item = SymbolRef>) -> Self {
85 self.reads = reads.into_iter().collect();
86 self
87 }
88}
89
90/// How [`analyze`] must treat a footprint whose blast radius could not be fully
91/// resolved. The variants are ordered by conservatism: `Precise` < `FileLevel` <
92/// `Serialize`. See the module docs for the soundness argument behind each.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Scheduling {
95 /// Blast radius fully resolved — schedule on exact symbol-overlap.
96 Precise,
97 /// Blast radius unresolved (a declared symbol is new/unknown, so it has no
98 /// existing callers to bound) but the declared write *files* are trustworthy.
99 /// Schedule on file-level disjointness.
100 FileLevel,
101 /// Neither blast radius nor file set trustworthy (truncated index, or
102 /// planner-declared uncertain). Conflict with everything — fail-closed.
103 Serialize,
104}
105
106/// A footprint that has been through [`expand_footprint`] (or whose caller has
107/// explicitly asserted it needs no expansion). [`analyze`] accepts only this, so
108/// a caller cannot *accidentally* schedule on a raw declared footprint — which
109/// would carry `Scheduling::Precise` and no blast radius, silently losing the
110/// fail-closed guarantee.
111#[derive(Debug, Clone)]
112pub struct ExpandedFootprint {
113 expanded: SymbolFootprint,
114 /// Declared write *files* (repo-relative) — the basis for `FileLevel`
115 /// scheduling. Always the *declared* writes, never the expanded blast radius:
116 /// the agent edits only what it declared, so only those files can produce a
117 /// worktree merge conflict; callers in the blast radius are semantic impact
118 /// the gate checks, not files the subtask touches.
119 declared_files: BTreeSet<String>,
120 scheduling: Scheduling,
121}
122
123impl ExpandedFootprint {
124 /// Escape hatch for callers that genuinely have no [`ProjectIndex`] (and for
125 /// tests): you assert the footprint is already as-expanded-as-it-gets. A
126 /// declared-`uncertain` footprint maps to [`Scheduling::Serialize`] (no index
127 /// means no basis to downgrade to file-level safely). Prefer
128 /// [`expand_footprint`]; reach for this only when there is no index.
129 pub fn assume_expanded(footprint: SymbolFootprint) -> Self {
130 let declared_files = footprint.writes.iter().map(|w| w.file.clone()).collect();
131 let scheduling = if footprint.uncertain {
132 Scheduling::Serialize
133 } else {
134 Scheduling::Precise
135 };
136 Self {
137 expanded: footprint,
138 declared_files,
139 scheduling,
140 }
141 }
142
143 pub fn inner(&self) -> &SymbolFootprint {
144 &self.expanded
145 }
146
147 pub fn scheduling(&self) -> Scheduling {
148 self.scheduling
149 }
150
151 /// Declared write files (repo-relative) — the `FileLevel` scheduling basis.
152 pub fn declared_files(&self) -> &BTreeSet<String> {
153 &self.declared_files
154 }
155}
156
157/// A subtask paired with its expanded footprint, for [`analyze`].
158#[derive(Debug, Clone)]
159pub struct FootprintSubtask {
160 pub id: String,
161 pub footprint: ExpandedFootprint,
162}
163
164/// The scheduling plan: parallel levels plus the dependency/conflict structure
165/// that produced them (kept for inspection and the audit trail).
166#[derive(Debug, Default, PartialEq, Eq)]
167pub struct DecompositionPlan {
168 /// Subtask ids grouped into levels; ids within a level may run concurrently.
169 pub levels: Vec<Vec<String>>,
170 /// `(a, b)`: b reads something a writes, so a must run before b.
171 pub edges: Vec<(String, String)>,
172 /// `(a, b)`: a and b write an overlapping symbol (or one is uncertain), so
173 /// they were placed in different levels.
174 pub conflicts: Vec<(String, String)>,
175}
176
177/// Expand a declared footprint to its blast radius: every transitive caller of a
178/// written symbol is also (potentially) affected and joins the write-set, up to
179/// `max_depth` hops. Fail-closed: a truncated index, or a declared symbol the
180/// index doesn't know, marks the result `uncertain`.
181pub fn expand_footprint(
182 index: &ProjectIndex,
183 declared: &SymbolFootprint,
184 max_depth: usize,
185) -> ExpandedFootprint {
186 let declared_files: BTreeSet<String> = declared.writes.iter().map(|w| w.file.clone()).collect();
187 let mut writes = declared.writes.clone();
188 // Scheduling conservatism only ever ratchets UP (Precise → FileLevel →
189 // Serialize), never down. Serialize is decided ONCE here, pre-loop: a
190 // truncated index can't tell a new symbol from one with hidden callers, and a
191 // planner-declared uncertain footprint is untrusted outright — both go
192 // straight to Serialize (the Phase-0 fail-open fix). The in-loop expansion
193 // below can only ever ratchet Precise → FileLevel, never reach Serialize and
194 // never step back down.
195 let mut scheduling = if declared.uncertain || index.truncated {
196 Scheduling::Serialize
197 } else {
198 Scheduling::Precise
199 };
200
201 let mut frontier: Vec<SymbolRef> = declared.writes.iter().cloned().collect();
202 for _ in 0..max_depth {
203 let mut next = Vec::new();
204 for w in &frontier {
205 // The index has never heard of this symbol — it's new (greenfield).
206 // A symbol that doesn't exist yet has no existing callers, so there is
207 // no blast radius to miss; the declared write file is still real.
208 // Downgrade to file-level scheduling (not Serialize) unless we are
209 // already forced higher by a truncated index / declared uncertainty.
210 if index.find(&w.symbol).is_empty() {
211 if scheduling == Scheduling::Precise {
212 scheduling = Scheduling::FileLevel;
213 }
214 continue;
215 }
216 for cref in index.callers_of(&w.symbol) {
217 let caller = SymbolRef::new(cref.from_file.clone(), cref.from_symbol.clone());
218 if writes.insert(caller.clone()) {
219 next.push(caller);
220 }
221 }
222 }
223 if next.is_empty() {
224 break;
225 }
226 frontier = next;
227 }
228
229 ExpandedFootprint {
230 expanded: SymbolFootprint {
231 writes,
232 reads: declared.reads.clone(),
233 // `uncertain` stays the inspection/back-compat view: true whenever the
234 // blast radius wasn't fully resolved (FileLevel or Serialize).
235 uncertain: scheduling != Scheduling::Precise,
236 },
237 declared_files,
238 scheduling,
239 }
240}
241
242/// Schedule subtasks into parallel levels from their footprints. Deterministic:
243/// ties break on subtask id. Cyclic read/write dependencies (which cannot be
244/// satisfied) are broken by serializing the remaining subtasks one per level.
245pub fn analyze(subtasks: &[FootprintSubtask]) -> DecompositionPlan {
246 let n = subtasks.len();
247 let mut conflict_pairs: HashSet<(usize, usize)> = HashSet::new();
248 let mut deps: HashMap<usize, BTreeSet<usize>> = (0..n).map(|i| (i, BTreeSet::new())).collect();
249 let mut conflicts = Vec::new();
250 let mut edges = Vec::new();
251
252 for i in 0..n {
253 for j in (i + 1)..n {
254 let fa = &subtasks[i].footprint;
255 let fb = &subtasks[j].footprint;
256 let a = fa.inner();
257 let b = fb.inner();
258
259 // Conflict basis depends on how much each footprint can be trusted:
260 // - either Serialize → always conflict (fail-closed)
261 // - both Precise → exact symbol blast-radius overlap
262 // - otherwise (≥1 FileLevel,
263 // neither Serialize) → declared write-FILE overlap
264 // Exhaustive on purpose (no `_`): a future Scheduling variant must
265 // force a decision here, not silently inherit file-level scheduling
266 // — which for a *more*-conservative variant would be the exact
267 // fail-open hole this module exists to prevent (CLAUDE.md rule #2).
268 let conflict = match (fa.scheduling(), fb.scheduling()) {
269 // Either side untrusted even at file level → always conflict.
270 (Scheduling::Serialize, _) | (_, Scheduling::Serialize) => true,
271 // Both fully resolved → exact symbol blast-radius overlap.
272 (Scheduling::Precise, Scheduling::Precise) => !a.writes.is_disjoint(&b.writes),
273 // ≥1 FileLevel, neither Serialize → declared write-FILE overlap.
274 (Scheduling::FileLevel, Scheduling::FileLevel)
275 | (Scheduling::FileLevel, Scheduling::Precise)
276 | (Scheduling::Precise, Scheduling::FileLevel) => {
277 !fa.declared_files().is_disjoint(fb.declared_files())
278 }
279 };
280 if conflict {
281 conflict_pairs.insert((i, j));
282 conflict_pairs.insert((j, i));
283 conflicts.push((subtasks[i].id.clone(), subtasks[j].id.clone()));
284 }
285
286 // Edge a→b: b reads what a writes (b runs after a).
287 if !a.writes.is_disjoint(&b.reads) {
288 deps.get_mut(&j).unwrap().insert(i);
289 edges.push((subtasks[i].id.clone(), subtasks[j].id.clone()));
290 }
291 // Edge b→a: a reads what b writes.
292 if !b.writes.is_disjoint(&a.reads) {
293 deps.get_mut(&i).unwrap().insert(j);
294 edges.push((subtasks[j].id.clone(), subtasks[i].id.clone()));
295 }
296 }
297 }
298
299 let mut placed = vec![false; n];
300 let mut levels: Vec<Vec<String>> = Vec::new();
301
302 while placed.iter().any(|p| !p) {
303 // Ready = not placed, all dependencies already placed.
304 let ready: Vec<usize> = (0..n)
305 .filter(|&i| !placed[i] && deps[&i].iter().all(|d| placed[*d]))
306 .collect();
307
308 let mut chosen: Vec<usize> = if ready.is_empty() {
309 // Dependency cycle: break it by serializing the lowest remaining id.
310 vec![(0..n).find(|&i| !placed[i]).unwrap()]
311 } else {
312 let mut level: Vec<usize> = Vec::new();
313 for &i in &ready {
314 if level.iter().all(|&k| !conflict_pairs.contains(&(i, k))) {
315 level.push(i);
316 }
317 }
318 level
319 };
320 chosen.sort();
321
322 for &i in &chosen {
323 placed[i] = true;
324 }
325 levels.push(chosen.into_iter().map(|i| subtasks[i].id.clone()).collect());
326 }
327
328 DecompositionPlan {
329 levels,
330 edges,
331 conflicts,
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 fn sub(id: &str, fp: SymbolFootprint) -> FootprintSubtask {
340 FootprintSubtask {
341 id: id.to_string(),
342 footprint: ExpandedFootprint::assume_expanded(fp),
343 }
344 }
345
346 /// Build a subtask with an explicit [`Scheduling`] (tests can reach the
347 /// private fields because they are a child module).
348 fn sub_sched(id: &str, fp: SymbolFootprint, scheduling: Scheduling) -> FootprintSubtask {
349 let declared_files = fp.writes.iter().map(|w| w.file.clone()).collect();
350 FootprintSubtask {
351 id: id.to_string(),
352 footprint: ExpandedFootprint {
353 expanded: fp,
354 declared_files,
355 scheduling,
356 },
357 }
358 }
359
360 fn w(file: &str, sym: &str) -> SymbolRef {
361 SymbolRef::new(file, sym)
362 }
363
364 #[test]
365 fn disjoint_writes_run_in_one_level() {
366 let plan = analyze(&[
367 sub("a", SymbolFootprint::writing([w("a.rs", "fa")])),
368 sub("b", SymbolFootprint::writing([w("b.rs", "fb")])),
369 ]);
370 assert_eq!(plan.levels, vec![vec!["a".to_string(), "b".to_string()]]);
371 assert!(plan.conflicts.is_empty());
372 }
373
374 #[test]
375 fn overlapping_writes_serialize() {
376 let plan = analyze(&[
377 sub("a", SymbolFootprint::writing([w("lib.rs", "shared")])),
378 sub("b", SymbolFootprint::writing([w("lib.rs", "shared")])),
379 ]);
380 assert_eq!(plan.levels.len(), 2, "{plan:?}");
381 assert_eq!(plan.conflicts.len(), 1);
382 }
383
384 #[test]
385 fn write_read_dependency_orders_levels() {
386 // b reads what a writes → a before b.
387 let a = SymbolFootprint::writing([w("lib.rs", "api")]);
388 let b = SymbolFootprint::default().with_reads([w("lib.rs", "api")]);
389 let plan = analyze(&[sub("a", a), sub("b", b)]);
390 assert_eq!(
391 plan.levels,
392 vec![vec!["a".to_string()], vec!["b".to_string()]]
393 );
394 assert_eq!(plan.edges, vec![("a".to_string(), "b".to_string())]);
395 }
396
397 #[test]
398 fn uncertain_subtask_conflicts_with_everything() {
399 let mut uncertain = SymbolFootprint::writing([w("x.rs", "fx")]);
400 uncertain.uncertain = true;
401 let plan = analyze(&[
402 sub("u", uncertain),
403 sub("a", SymbolFootprint::writing([w("a.rs", "fa")])),
404 sub("b", SymbolFootprint::writing([w("b.rs", "fb")])),
405 ]);
406 // u must not share a level with a or b.
407 for level in &plan.levels {
408 if level.contains(&"u".to_string()) {
409 assert_eq!(level.len(), 1, "uncertain subtask is isolated: {plan:?}");
410 }
411 }
412 assert_eq!(plan.conflicts.len(), 2, "u conflicts with both");
413 }
414
415 #[test]
416 fn file_level_disjoint_files_run_in_parallel() {
417 // The greenfield fix: two subtasks creating NEW symbols (unknown to the
418 // index → FileLevel) in DIFFERENT files run concurrently instead of
419 // serializing. This is what unblocks the parallel-wins regime for
420 // create-new-symbol work.
421 let plan = analyze(&[
422 sub_sched(
423 "a",
424 SymbolFootprint::writing([w("a.rs", "new_a")]),
425 Scheduling::FileLevel,
426 ),
427 sub_sched(
428 "b",
429 SymbolFootprint::writing([w("b.rs", "new_b")]),
430 Scheduling::FileLevel,
431 ),
432 ]);
433 assert_eq!(
434 plan.levels,
435 vec![vec!["a".to_string(), "b".to_string()]],
436 "{plan:?}"
437 );
438 assert!(
439 plan.conflicts.is_empty(),
440 "disjoint files do not conflict: {plan:?}"
441 );
442 }
443
444 #[test]
445 fn file_level_same_file_serializes() {
446 // FileLevel still serializes same-file work — the agents would otherwise
447 // race the same file and merge-conflict at integration.
448 let plan = analyze(&[
449 sub_sched(
450 "a",
451 SymbolFootprint::writing([w("lib.rs", "new_a")]),
452 Scheduling::FileLevel,
453 ),
454 sub_sched(
455 "b",
456 SymbolFootprint::writing([w("lib.rs", "new_b")]),
457 Scheduling::FileLevel,
458 ),
459 ]);
460 assert_eq!(
461 plan.levels.len(),
462 2,
463 "same file → separate levels: {plan:?}"
464 );
465 assert_eq!(plan.conflicts.len(), 1);
466 }
467
468 #[test]
469 fn mixed_precise_and_file_level_compares_at_file_level() {
470 // A is Precise, B is FileLevel. The weaker (file) basis governs the pair.
471 // Disjoint files → parallel.
472 let parallel = analyze(&[
473 sub_sched(
474 "a",
475 SymbolFootprint::writing([w("a.rs", "fa")]),
476 Scheduling::Precise,
477 ),
478 sub_sched(
479 "b",
480 SymbolFootprint::writing([w("b.rs", "new_b")]),
481 Scheduling::FileLevel,
482 ),
483 ]);
484 assert_eq!(
485 parallel.levels,
486 vec![vec!["a".to_string(), "b".to_string()]],
487 "{parallel:?}"
488 );
489
490 // Same file → serialize even though the symbols differ (file basis).
491 let serial = analyze(&[
492 sub_sched(
493 "a",
494 SymbolFootprint::writing([w("lib.rs", "fa")]),
495 Scheduling::Precise,
496 ),
497 sub_sched(
498 "b",
499 SymbolFootprint::writing([w("lib.rs", "new_b")]),
500 Scheduling::FileLevel,
501 ),
502 ]);
503 assert_eq!(serial.levels.len(), 2, "{serial:?}");
504 }
505
506 #[test]
507 fn serialize_conflicts_with_everything_even_disjoint_files() {
508 // A truncated-index / planner-uncertain subtask (Serialize) still
509 // conflicts with everyone, even file-disjoint work — the Phase-0 fix.
510 let plan = analyze(&[
511 sub_sched(
512 "s",
513 SymbolFootprint::writing([w("s.rs", "fs")]),
514 Scheduling::Serialize,
515 ),
516 sub_sched(
517 "a",
518 SymbolFootprint::writing([w("a.rs", "fa")]),
519 Scheduling::FileLevel,
520 ),
521 sub_sched(
522 "b",
523 SymbolFootprint::writing([w("b.rs", "fb")]),
524 Scheduling::Precise,
525 ),
526 ]);
527 for level in &plan.levels {
528 if level.contains(&"s".to_string()) {
529 assert_eq!(level.len(), 1, "Serialize subtask is isolated: {plan:?}");
530 }
531 }
532 assert_eq!(plan.conflicts.len(), 2, "s conflicts with both a and b");
533 }
534
535 #[test]
536 fn file_level_still_honors_read_write_edges() {
537 // Even when FileLevel, a declared read/write dependency still orders the
538 // levels — the declared symbols remain meaningful for edges.
539 let writer = SymbolFootprint::writing([w("a.rs", "api")]);
540 let reader = SymbolFootprint::default().with_reads([w("a.rs", "api")]);
541 let plan = analyze(&[
542 sub_sched("writer", writer, Scheduling::FileLevel),
543 sub_sched("reader", reader, Scheduling::FileLevel),
544 ]);
545 assert_eq!(
546 plan.levels,
547 vec![vec!["writer".to_string()], vec!["reader".to_string()]],
548 "reader runs after writer: {plan:?}"
549 );
550 }
551
552 #[test]
553 fn cyclic_dependency_is_broken_by_serializing() {
554 // a writes X reads Y; b writes Y reads X → mutual dependency cycle.
555 let a = SymbolFootprint {
556 writes: [w("lib.rs", "X")].into_iter().collect(),
557 reads: [w("lib.rs", "Y")].into_iter().collect(),
558 uncertain: false,
559 };
560 let b = SymbolFootprint {
561 writes: [w("lib.rs", "Y")].into_iter().collect(),
562 reads: [w("lib.rs", "X")].into_iter().collect(),
563 uncertain: false,
564 };
565 let plan = analyze(&[sub("a", a), sub("b", b)]);
566 // Cannot satisfy both orders — must serialize, not deadlock.
567 assert_eq!(plan.levels.len(), 2, "{plan:?}");
568 }
569
570 #[test]
571 fn expand_marks_unknown_symbol_file_level() {
572 // A new/unknown symbol downgrades to FileLevel (not Serialize): no
573 // existing callers to miss, declared file still trustworthy.
574 let dir = tempfile::tempdir().unwrap();
575 std::fs::write(dir.path().join("lib.rs"), "pub fn known() {}\n").unwrap();
576 let index = ProjectIndex::build(dir.path());
577
578 let declared = SymbolFootprint::writing([w("lib.rs", "does_not_exist")]);
579 let expanded = expand_footprint(&index, &declared, 3);
580 assert_eq!(expanded.scheduling(), Scheduling::FileLevel);
581 assert!(
582 expanded.inner().uncertain,
583 "unknown symbol is still 'uncertain' for inspection"
584 );
585 assert!(expanded.declared_files().contains("lib.rs"));
586 }
587
588 #[test]
589 fn expand_known_symbol_is_precise() {
590 let dir = tempfile::tempdir().unwrap();
591 std::fs::write(dir.path().join("lib.rs"), "pub fn known() {}\n").unwrap();
592 let index = ProjectIndex::build(dir.path());
593
594 let declared = SymbolFootprint::writing([w("lib.rs", "known")]);
595 let expanded = expand_footprint(&index, &declared, 3);
596 assert_eq!(expanded.scheduling(), Scheduling::Precise);
597 assert!(!expanded.inner().uncertain);
598 }
599
600 #[test]
601 fn truncated_index_forces_serialize_even_for_known_symbol() {
602 // The Phase-0 fail-open fix, preserved EXACTLY: a truncated index has
603 // incomplete references, so expansion from it is Serialize (conflicts with
604 // everything) — NOT downgraded to file-level. Callers exist but are hidden.
605 let dir = tempfile::tempdir().unwrap();
606 std::fs::write(dir.path().join("lib.rs"), "pub fn known() {}\n").unwrap();
607 let mut index = ProjectIndex::build(dir.path());
608 assert!(!index.truncated, "small build is not truncated");
609 index.truncated = true; // simulate a budget-truncated build
610
611 let declared = SymbolFootprint::writing([w("lib.rs", "known")]);
612 let expanded = expand_footprint(&index, &declared, 3);
613 assert_eq!(
614 expanded.scheduling(),
615 Scheduling::Serialize,
616 "truncated index must stay fail-closed, not relax to file-level"
617 );
618 }
619
620 #[test]
621 fn greenfield_disjoint_files_parallelize_end_to_end() {
622 // The full fix, through expand + analyze: two subtasks each creating a new
623 // function in its own file, against a real index that doesn't know either
624 // symbol yet, schedule into ONE parallel level.
625 let dir = tempfile::tempdir().unwrap();
626 std::fs::write(dir.path().join("a.rs"), "// implement alpha\n").unwrap();
627 std::fs::write(dir.path().join("b.rs"), "// implement beta\n").unwrap();
628 let index = ProjectIndex::build(dir.path());
629
630 let fa = expand_footprint(&index, &SymbolFootprint::writing([w("a.rs", "alpha")]), 3);
631 let fb = expand_footprint(&index, &SymbolFootprint::writing([w("b.rs", "beta")]), 3);
632 assert_eq!(fa.scheduling(), Scheduling::FileLevel);
633 assert_eq!(fb.scheduling(), Scheduling::FileLevel);
634
635 let plan = analyze(&[
636 FootprintSubtask {
637 id: "a".into(),
638 footprint: fa,
639 },
640 FootprintSubtask {
641 id: "b".into(),
642 footprint: fb,
643 },
644 ]);
645 assert_eq!(
646 plan.levels,
647 vec![vec!["a".to_string(), "b".to_string()]],
648 "greenfield disjoint-file subtasks now run in parallel: {plan:?}"
649 );
650 }
651}