@@ -28,6 +28,9 @@ use crate::{
2828/// value is only ever `Some` when tracking happened (see [`observe_fspy`]).
2929struct TrackingOutcome {
3030 path_reads : HashMap < RelativePathBuf , PathRead > ,
31+ /// Auto-output writes after output exclusions are applied. Empty when
32+ /// `output_config.includes_auto` is false.
33+ path_writes : FxHashSet < RelativePathBuf > ,
3134 /// First path that was both read and written during execution, if any.
3235 /// A non-empty value means caching this task is unsound.
3336 read_write_overlap : Option < RelativePathBuf > ,
@@ -58,7 +61,6 @@ pub(super) async fn update_cache(
5861) -> ( CacheUpdateStatus , Option < ExecutionError > ) {
5962 let CacheState { metadata, globbed_inputs, std_outputs, tracking } = state;
6063 let fspy = tracking. fspy . as_ref ( ) ;
61- let input_negative_globs = fspy. map ( |t| t. input_negative_globs . as_slice ( ) ) ;
6264
6365 if let Some ( reports) = reports
6466 && reports. cache_disabled
@@ -89,7 +91,8 @@ pub(super) async fn update_cache(
8991
9092 let fspy_outcome = observe_fspy (
9193 outcome,
92- input_negative_globs,
94+ metadata,
95+ fspy,
9396 & ignored_input_rels,
9497 & ignored_output_rels,
9598 workspace_root,
@@ -150,7 +153,12 @@ pub(super) async fn update_cache(
150153 }
151154 } ;
152155
153- let output_archive = match collect_and_archive_outputs ( metadata, workspace_root, cache_dir) {
156+ let output_archive = match collect_and_archive_outputs (
157+ metadata,
158+ fspy_outcome. as_ref ( ) ,
159+ workspace_root,
160+ cache_dir,
161+ ) {
154162 Ok ( archive) => archive,
155163 Err ( err) => {
156164 return (
@@ -177,12 +185,18 @@ pub(super) async fn update_cache(
177185}
178186
179187/// Summarize the run's fspy observations. `Some` iff tracking was both
180- /// requested (`input_negative_globs .is_some()`) and compiled in (`cfg(fspy)`). On a
188+ /// requested (`tracking.fspy .is_some()`) and compiled in (`cfg(fspy)`). On a
181189/// `cfg(not(fspy))` build this is always `None`, and [`update_cache`]
182190/// short-circuits to `FspyUnsupported` when tracking was needed.
191+ ///
192+ /// `path_reads` is gated on `input_config.includes_auto`, filtered by
193+ /// user-configured input negatives, and by tool-reported `ignoreInput` paths.
194+ /// `path_writes` is filtered by user-configured output negatives and
195+ /// tool-reported `ignoreOutput` paths before read-write overlap detection.
183196fn observe_fspy (
184197 outcome : & ChildOutcome ,
185- input_negative_globs : Option < & [ wax:: Glob < ' static > ] > ,
198+ metadata : & CacheMetadata ,
199+ fspy : Option < & super :: FspyTracking > ,
186200 ignored_input_rels : & FxHashSet < RelativePathBuf > ,
187201 ignored_output_rels : & FxHashSet < RelativePathBuf > ,
188202 workspace_root : & AbsolutePath ,
@@ -191,31 +205,56 @@ fn observe_fspy(
191205 {
192206 use super :: tracked_accesses:: TrackedPathAccesses ;
193207
194- outcome. path_accesses . as_ref ( ) . zip ( input_negative_globs) . map ( |( raw, negatives) | {
195- let tracked = TrackedPathAccesses :: from_raw ( raw, workspace_root, negatives) ;
196- let path_reads: HashMap < RelativePathBuf , PathRead > = tracked
197- . path_reads
198- . into_iter ( )
199- . filter ( |( path, _) | !is_ignored ( path, ignored_input_rels) )
200- . collect ( ) ;
201- let path_writes: FxHashSet < RelativePathBuf > = tracked
202- . path_writes
203- . into_iter ( )
204- . filter ( |path| !is_ignored ( path, ignored_output_rels) )
205- . collect ( ) ;
206- let read_write_overlap = path_reads. keys ( ) . find ( |p| path_writes. contains ( * p) ) . cloned ( ) ;
207- TrackingOutcome { path_reads, read_write_overlap }
208+ outcome. path_accesses . as_ref ( ) . map ( |raw| {
209+ let tracked = TrackedPathAccesses :: from_raw ( raw, workspace_root) ;
210+ let filtered_path_reads: HashMap < RelativePathBuf , PathRead > =
211+ // fspy can be attached for auto-output-only tasks. In that
212+ // mode reads must not become inferred inputs.
213+ if metadata. input_config . includes_auto
214+ && let Some ( fspy) = fspy
215+ {
216+ tracked
217+ . path_reads
218+ . iter ( )
219+ . filter ( |( path, _) | {
220+ !matches_any_glob ( path, & fspy. input_negative_globs )
221+ && !is_ignored ( path, ignored_input_rels)
222+ } )
223+ . map ( |( path, read) | ( path. clone ( ) , * read) )
224+ . collect ( )
225+ } else {
226+ HashMap :: default ( )
227+ } ;
228+ let filtered_path_writes: FxHashSet < RelativePathBuf > =
229+ // fspy can also be attached for auto-input-only tasks. In that
230+ // mode writes must not become auto outputs or overlap candidates.
231+ if metadata. output_config . includes_auto
232+ && let Some ( fspy) = fspy
233+ {
234+ tracked
235+ . path_writes
236+ . iter ( )
237+ . filter ( |path| {
238+ !matches_any_glob ( path, & fspy. output_negative_globs )
239+ && !is_ignored ( path, ignored_output_rels)
240+ } )
241+ . cloned ( )
242+ . collect ( )
243+ } else {
244+ FxHashSet :: default ( )
245+ } ;
246+ let read_write_overlap =
247+ filtered_path_reads. keys ( ) . find ( |p| filtered_path_writes. contains ( * p) ) . cloned ( ) ;
248+ TrackingOutcome {
249+ path_reads : filtered_path_reads,
250+ path_writes : filtered_path_writes,
251+ read_write_overlap,
252+ }
208253 } )
209254 }
210255 #[ cfg( not( fspy) ) ]
211256 {
212- let _ = (
213- outcome,
214- input_negative_globs,
215- ignored_input_rels,
216- ignored_output_rels,
217- workspace_root,
218- ) ;
257+ let _ = ( outcome, metadata, fspy, ignored_input_rels, ignored_output_rels, workspace_root) ;
219258 None
220259 }
221260}
@@ -256,6 +295,12 @@ fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet<RelativePathBuf>) -> b
256295 ignored. contains ( path) || ignored. iter ( ) . any ( |ig| path. strip_prefix ( ig) . is_some ( ) )
257296}
258297
298+ fn matches_any_glob ( path : & RelativePathBuf , globs : & [ wax:: Glob < ' static > ] ) -> bool {
299+ use wax:: Program as _;
300+
301+ globs. iter ( ) . any ( |glob| glob. is_match ( path. as_str ( ) ) )
302+ }
303+
259304/// Select tool-reported env records to embed in the post-run fingerprint.
260305/// Names that the user already declared as fingerprinted are skipped because
261306/// their values are already in the spawn fingerprint.
@@ -309,36 +354,49 @@ fn collect_tracked_env_globs(reports: &Reports) -> anyhow::Result<TrackedEnvGlob
309354 Ok ( tracked_env_globs)
310355}
311356
312- /// Collect output files matching the configured globs and create a tar.zst
313- /// archive in the cache directory.
357+ /// Collect output files and create a tar.zst archive in the cache directory.
358+ ///
359+ /// Output files are determined by:
360+ /// - fspy-tracked writes (already empty when `output_config.includes_auto` is false)
361+ /// - Positive output globs (always, if configured)
362+ /// - Negative output globs and tool-reported `ignoreOutput` paths filter
363+ /// fspy-tracked writes before this function receives them
314364///
315- /// Returns `Some(archive_filename)` if files were archived, `None` if the
316- /// output config has no positive globs or no files matched.
365+ /// Returns `Some(archive_filename)` if files were archived, `None` if no output files.
317366fn collect_and_archive_outputs (
318367 cache_metadata : & CacheMetadata ,
368+ tracking : Option < & TrackingOutcome > ,
319369 workspace_root : & AbsolutePath ,
320370 cache_dir : & AbsolutePath ,
321371) -> anyhow:: Result < Option < Str > > {
322372 let output_config = & cache_metadata. output_config ;
323373
324- if output_config. positive_globs . is_empty ( ) {
325- return Ok ( None ) ;
374+ let mut output_files: FxHashSet < RelativePathBuf > = FxHashSet :: default ( ) ;
375+
376+ if let Some ( t) = tracking {
377+ output_files. extend ( t. path_writes . iter ( ) . cloned ( ) ) ;
326378 }
327379
328- let output_files = glob:: collect_glob_paths (
329- workspace_root,
330- & output_config. positive_globs ,
331- & output_config. negative_globs ,
332- ) ?;
380+ if !output_config. positive_globs . is_empty ( ) {
381+ let glob_paths = glob:: collect_glob_paths (
382+ workspace_root,
383+ & output_config. positive_globs ,
384+ & output_config. negative_globs ,
385+ ) ?;
386+ output_files. extend ( glob_paths) ;
387+ }
333388
334389 if output_files. is_empty ( ) {
335390 return Ok ( None ) ;
336391 }
337392
393+ let mut sorted_files: Vec < RelativePathBuf > = output_files. into_iter ( ) . collect ( ) ;
394+ sorted_files. sort ( ) ;
395+
338396 let archive_name: Str = vite_str:: format!( "{}.tar.zst" , uuid:: Uuid :: new_v4( ) ) ;
339397 let archive_path = cache_dir. join ( archive_name. as_str ( ) ) ;
340398
341- archive:: create_output_archive ( workspace_root, & output_files , & archive_path) ?;
399+ archive:: create_output_archive ( workspace_root, & sorted_files , & archive_path) ?;
342400
343401 Ok ( Some ( archive_name) )
344402}
0 commit comments