Skip to main content

fallow_engine/
change_scope.rs

1//! The Git change scope of one analysis run.
2//!
3//! A run narrows its findings by at most one change scope: the changed files
4//! of one global ref, or the per-workspace refs of `workspaces.changedSince`.
5//! Every surface describes its inputs with a [`ChangeScopeRequest`] and calls
6//! [`ChangeScope::resolve`], which owns the precedence rule and the failure
7//! policy. The resolved value owns the four things that must agree: the
8//! result filter, the `package_baselines` provenance rows, the
9//! `package-baselines` request outcome, and the scope flag that baseline
10//! comparison and finding-id queries read. A surface therefore cannot apply
11//! the filter and forget the flag, or read the package map where the caller
12//! owns the scope.
13//!
14//! The failure policy follows `--changed-since`. A malformed key or ref is
15//! invalid input, and resolution fails. A map that cannot apply as written
16//! stands down as a whole: a key that names no workspace of this project (for
17//! example in a run from a package subdirectory), or a well-formed ref that
18//! Git cannot resolve (for example in a shallow CI clone). The run then
19//! reports in full scope and publishes a `not-applied` request outcome that
20//! says so. The report is wider than asked, never narrower.
21//!
22//! The filter applies to the final result of the run. A surface that narrows
23//! findings before type-aware refinement, to reduce sidecar work, applies the
24//! same scope again after refinement, because refinement can add findings.
25
26use std::path::{Path, PathBuf};
27use std::sync::OnceLock;
28
29use fallow_config::{ResolvedConfig, WorkspaceInfo};
30use fallow_output::{PackageBaselineStatus, RequestName, RequestOutcome};
31use fallow_types::duplicates::DuplicationReport;
32use fallow_types::results::AnalysisResults;
33use rustc_hash::FxHashSet;
34
35use crate::changed_files::{ChangedFilesError, NormalizedChangedFiles};
36use crate::package_baselines::{PackageBaselineError, PackageChangeScope};
37
38/// The `requested` value of the `package-baselines` request outcome.
39const PACKAGE_BASELINES_REQUEST: &str = "workspaces.changedSince";
40
41/// Who owns the change scope of a run.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum ChangeScopeOwner {
44    /// The run owns it: a global ref when one is requested, otherwise the
45    /// configured package baselines.
46    #[default]
47    Run,
48    /// The calling pipeline narrows the findings itself. `audit` is the
49    /// example: it compares a head run and a base run against its own changed
50    /// files. The run reads no package baselines, so it never resolves Git
51    /// refs in a base snapshot and never hides a finding that the comparison
52    /// needs.
53    Caller,
54}
55
56/// The change-scope inputs of one run.
57#[derive(Debug, Clone, Copy, Default)]
58pub struct ChangeScopeRequest<'a> {
59    /// Who owns the scope.
60    pub owner: ChangeScopeOwner,
61    /// A global changed-since ref was requested. This is `true` also when the
62    /// ref did not resolve: the run then reports in full scope, and the
63    /// package baselines still do not apply.
64    pub global_ref: bool,
65    /// The changed files of the global ref, or a changed-file set that the
66    /// caller supplied.
67    pub files: Option<&'a FxHashSet<PathBuf>>,
68    /// The run-wide memo of the resolved package map. The analyses of one run
69    /// share it, so Git resolves each mapped ref once per run. `None`
70    /// resolves the map without a memo.
71    pub cache: Option<&'a PackageBaselineCache>,
72    /// `--no-package-baselines`: this run ignores `workspaces.changedSince`
73    /// and reports every package in full scope, for example to save or gate a
74    /// whole-project baseline.
75    pub no_package_baselines: bool,
76}
77
78impl ChangeScopeRequest<'_> {
79    /// Whether [`ChangeScope::resolve`] reads `workspaces.changedSince` for
80    /// this request. A surface that discovers workspaces only for the package
81    /// map checks this first.
82    #[must_use]
83    pub fn reads_package_baselines(&self, config: &ResolvedConfig) -> bool {
84        self.owner == ChangeScopeOwner::Run
85            && !self.no_package_baselines
86            && !self.global_ref
87            && self.files.is_none()
88            && !config.workspace_changed_since.is_empty()
89    }
90}
91
92/// The package map of one run, resolved once.
93#[derive(Debug, Clone)]
94struct ResolvedPackages {
95    packages: Option<PackageChangeScope>,
96    outcome: RequestOutcome,
97}
98
99/// The run-wide memo of the resolved package map.
100///
101/// Every analysis of one run resolves the same map against the same
102/// workspaces, so each surface keeps one memo per run: a process-wide value on
103/// the CLI, a field of the programmatic call context, and one value per
104/// project and run in the editor. The first resolution wins. A config error is
105/// kept too, so every analysis of the run fails the same way.
106#[derive(Debug, Default)]
107pub struct PackageBaselineCache(OnceLock<Result<ResolvedPackages, PackageBaselineError>>);
108
109impl PackageBaselineCache {
110    /// An empty memo.
111    #[must_use]
112    pub const fn new() -> Self {
113        Self(OnceLock::new())
114    }
115
116    /// Whether an analysis of the run already resolved the map.
117    #[must_use]
118    pub fn is_resolved(&self) -> bool {
119        self.0.get().is_some()
120    }
121
122    /// The `package-baselines` request outcome of the run, once a run
123    /// resolved the map. `None` when no analysis read the map.
124    #[must_use]
125    pub fn request_outcome(&self) -> Option<RequestOutcome> {
126        match self.0.get()? {
127            Ok(resolved) => Some(resolved.outcome.clone()),
128            Err(_) => None,
129        }
130    }
131
132    fn resolve(
133        &self,
134        config: &ResolvedConfig,
135        workspaces: &[WorkspaceInfo],
136    ) -> Result<ResolvedPackages, PackageBaselineError> {
137        self.0
138            .get_or_init(|| resolve_packages(config, workspaces))
139            .clone()
140    }
141}
142
143fn resolve_packages(
144    config: &ResolvedConfig,
145    workspaces: &[WorkspaceInfo],
146) -> Result<ResolvedPackages, PackageBaselineError> {
147    match PackageChangeScope::resolve(&config.root, &config.workspace_changed_since, workspaces) {
148        Ok(packages) => Ok(ResolvedPackages {
149            packages,
150            outcome: RequestOutcome::applied(
151                RequestName::PackageBaselines,
152                PACKAGE_BASELINES_REQUEST,
153            ),
154        }),
155        Err(PackageBaselineError::Git {
156            key,
157            reference,
158            source,
159        }) if !matches!(source, ChangedFilesError::InvalidRef(_)) => {
160            let cause = source
161                .describe()
162                .split_whitespace()
163                .collect::<Vec<_>>()
164                .join(" ");
165            Ok(stood_down(
166                source.reason(),
167                &format!("the ref '{reference}' for '{key}' did not resolve ({cause})"),
168                "Fetch the ref with full history, or map the package to a ref Git can resolve.",
169            ))
170        }
171        Err(PackageBaselineError::UnknownWorkspace { key, suggestion }) => {
172            let hint =
173                suggestion.map_or_else(String::new, |root| format!(" (did you mean '{root}'?)"));
174            Ok(stood_down(
175                "unknown-workspace",
176                &format!("'{key}' names no workspace package of this project{hint}"),
177                "Use a workspace root as `fallow list --workspaces` prints it.",
178            ))
179        }
180        Err(err) => Err(err),
181    }
182}
183
184/// A package map that stood down: the run reports in full scope, and the
185/// outcome carries the sentence that the CLI also writes to stderr.
186fn stood_down(reason: &str, cause: &str, remedy: &str) -> ResolvedPackages {
187    ResolvedPackages {
188        packages: None,
189        outcome: RequestOutcome::not_applied(
190            RequestName::PackageBaselines,
191            PACKAGE_BASELINES_REQUEST,
192            reason,
193            format!(
194                "workspaces.changedSince was ignored because {cause}, so this report covers \
195                 every workspace package in full scope. {remedy}"
196            ),
197        ),
198    }
199}
200
201/// The resolved change scope of one run.
202#[derive(Debug, Clone, Default)]
203pub struct ChangeScope {
204    kind: ChangeScopeKind,
205    global_ref: bool,
206    outcome: Option<RequestOutcome>,
207}
208
209#[derive(Debug, Clone, Default)]
210enum ChangeScopeKind {
211    #[default]
212    Full,
213    Files(NormalizedChangedFiles),
214    Packages(PackageChangeScope),
215}
216
217impl ChangeScope {
218    /// Resolve the change scope of a run.
219    ///
220    /// A changed-file set wins. A requested global ref without files, or a
221    /// caller-owned scope, gives the full scope. Otherwise the configured
222    /// package baselines apply, when the config has any. A mapped ref that
223    /// Git cannot resolve stands the map down to the full scope.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error when the package map has a malformed key or Git ref,
228    /// or when a workspace root cannot be read.
229    pub fn resolve(
230        request: ChangeScopeRequest<'_>,
231        config: &ResolvedConfig,
232        workspaces: &[WorkspaceInfo],
233    ) -> Result<Self, PackageBaselineError> {
234        let global_ref = request.global_ref || request.files.is_some();
235        if let Some(files) = request.files {
236            return Ok(Self::changed_files(files));
237        }
238        if !request.reads_package_baselines(config) {
239            return Ok(Self {
240                global_ref,
241                ..Self::default()
242            });
243        }
244        let resolved = match request.cache {
245            Some(cache) => cache.resolve(config, workspaces)?,
246            None => resolve_packages(config, workspaces)?,
247        };
248        Ok(Self {
249            kind: resolved
250                .packages
251                .map_or(ChangeScopeKind::Full, ChangeScopeKind::Packages),
252            global_ref,
253            outcome: Some(resolved.outcome),
254        })
255    }
256
257    /// The scope of one global changed-file set.
258    #[must_use]
259    pub fn changed_files(files: &FxHashSet<PathBuf>) -> Self {
260        Self {
261            kind: ChangeScopeKind::Files(NormalizedChangedFiles::new(files)),
262            global_ref: true,
263            outcome: None,
264        }
265    }
266
267    /// The `scope_reasons` channel that narrowed the run, when a change ref
268    /// did: the package map, or a global ref. The saved-baseline comparison of
269    /// `check` and finding-id queries read this: a finding outside the scope
270    /// is hidden, not gone. `dupes` compares its baseline with the report
271    /// before the package map narrows it, so it reads only the global ref.
272    #[must_use]
273    pub const fn scope_reason(&self) -> Option<fallow_output::ScopeReason> {
274        match (&self.kind, self.global_ref) {
275            (ChangeScopeKind::Packages(_), _) => Some(fallow_output::ScopeReason::PackageBaselines),
276            (_, true) => Some(fallow_output::ScopeReason::ChangedSince),
277            (_, false) => None,
278        }
279    }
280
281    /// The applied package baselines, when the configured map scopes the run.
282    #[must_use]
283    pub const fn packages(&self) -> Option<&PackageChangeScope> {
284        match &self.kind {
285            ChangeScopeKind::Packages(packages) => Some(packages),
286            ChangeScopeKind::Full | ChangeScopeKind::Files(_) => None,
287        }
288    }
289
290    /// The `package-baselines` request outcome, when the run read the map.
291    #[must_use]
292    pub const fn request_outcome(&self) -> Option<&RequestOutcome> {
293        self.outcome.as_ref()
294    }
295
296    /// The sentence to show when the package map stood down, or `None` when
297    /// the map applied or the run did not read it.
298    #[must_use]
299    pub fn stand_down_message(&self) -> Option<&str> {
300        self.outcome
301            .as_ref()
302            .filter(|outcome| outcome.status != fallow_output::RequestStatus::Applied)
303            .and_then(|outcome| outcome.message.as_deref())
304    }
305
306    /// The `package_baselines` provenance rows of the run. Empty unless the
307    /// configured map scopes the run.
308    #[must_use]
309    pub fn package_baselines(&self) -> Vec<PackageBaselineStatus> {
310        self.packages().map_or_else(Vec::new, |packages| {
311            package_baseline_statuses(std::slice::from_ref(packages), packages.project_root())
312        })
313    }
314
315    /// Whether a finding owned by `path` is in scope.
316    #[must_use]
317    pub fn contains(&self, path: &Path) -> bool {
318        use crate::changed_files::ChangedPathScope as _;
319        match &self.kind {
320            ChangeScopeKind::Full => true,
321            ChangeScopeKind::Files(files) => files.contains(path),
322            ChangeScopeKind::Packages(packages) => packages.contains(path),
323        }
324    }
325
326    /// Keep the dead-code findings in scope.
327    pub(crate) fn retain_dead_code(&self, results: &mut AnalysisResults) {
328        match &self.kind {
329            ChangeScopeKind::Full => {}
330            ChangeScopeKind::Files(files) => {
331                crate::changed_files::filter_results_by_path_scope(results, files);
332            }
333            ChangeScopeKind::Packages(packages) => {
334                crate::changed_files::filter_results_by_path_scope(results, packages);
335            }
336        }
337    }
338
339    /// Keep the clone groups with at least one instance in scope.
340    pub(crate) fn retain_duplication(&self, report: &mut DuplicationReport, root: &Path) {
341        match &self.kind {
342            ChangeScopeKind::Full => {}
343            ChangeScopeKind::Files(files) => {
344                crate::changed_files::filter_duplication_by_path_scope(report, files, root);
345            }
346            ChangeScopeKind::Packages(packages) => {
347                crate::changed_files::filter_duplication_by_path_scope(report, packages, root);
348            }
349        }
350    }
351}
352
353/// The `package_baselines` rows of a combined report: the rows of the first
354/// section that applied the map. The sections of one run resolve the same map,
355/// so a section without rows either did not run or used a global scope.
356#[must_use]
357pub fn first_package_baselines<'a>(
358    sections: impl IntoIterator<Item = Option<&'a [PackageBaselineStatus]>>,
359) -> Vec<PackageBaselineStatus> {
360    sections
361        .into_iter()
362        .flatten()
363        .find(|rows| !rows.is_empty())
364        .map_or_else(Vec::new, <[PackageBaselineStatus]>::to_vec)
365}
366
367/// Project-relative package baseline rows, sorted by workspace root.
368///
369/// An editor that analyzes several project roots passes every applied scope
370/// and its own root, so the rows of all projects share one path base.
371#[must_use]
372pub fn package_baseline_statuses(
373    scopes: &[PackageChangeScope],
374    root: &Path,
375) -> Vec<PackageBaselineStatus> {
376    let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
377    let mut rows = scopes
378        .iter()
379        .flat_map(|scope| {
380            let prefix = scope
381                .project_root()
382                .strip_prefix(&root)
383                .map(|relative| {
384                    relative
385                        .components()
386                        .map(|component| component.as_os_str().to_string_lossy())
387                        .collect::<Vec<_>>()
388                        .join("/")
389                })
390                .unwrap_or_default();
391            scope
392                .configured_baselines()
393                .map(move |(key, reference)| PackageBaselineStatus {
394                    workspace_root: if prefix.is_empty() {
395                        key.to_owned()
396                    } else {
397                        format!("{prefix}/{key}")
398                    },
399                    reference: reference.to_owned(),
400                })
401        })
402        .collect::<Vec<_>>();
403    rows.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
404    rows
405}
406
407#[cfg(test)]
408mod tests {
409    use std::collections::BTreeMap;
410
411    use fallow_config::{FallowConfig, OutputFormat};
412
413    use super::*;
414
415    fn config_with_map(root: &Path, map: &[(&str, &str)]) -> ResolvedConfig {
416        let mut config = FallowConfig::default().resolve(
417            root.to_path_buf(),
418            OutputFormat::Json,
419            1,
420            true,
421            true,
422            None,
423        );
424        config.workspace_changed_since = map
425            .iter()
426            .map(|(key, reference)| ((*key).to_owned(), (*reference).to_owned()))
427            .collect::<BTreeMap<_, _>>();
428        config
429    }
430
431    /// A map that names no discovered workspace fails resolution. The tests
432    /// below use it to prove that a request never reads the map.
433    fn unknown_workspace_map(root: &Path) -> ResolvedConfig {
434        config_with_map(root, &[("packages/missing", "HEAD")])
435    }
436
437    #[test]
438    fn caller_owned_scope_never_reads_the_package_map() {
439        let temp = tempfile::tempdir().expect("tempdir");
440        let config = unknown_workspace_map(temp.path());
441        let request = ChangeScopeRequest {
442            owner: ChangeScopeOwner::Caller,
443            ..ChangeScopeRequest::default()
444        };
445        assert!(!request.reads_package_baselines(&config));
446        let scope = ChangeScope::resolve(request, &config, &[]).expect("caller-owned scope");
447        assert!(scope.scope_reason().is_none());
448        assert!(scope.package_baselines().is_empty());
449        assert!(scope.contains(&temp.path().join("packages/a/index.ts")));
450    }
451
452    #[test]
453    fn a_requested_global_ref_suppresses_the_map_even_without_files() {
454        let temp = tempfile::tempdir().expect("tempdir");
455        let config = unknown_workspace_map(temp.path());
456        let request = ChangeScopeRequest {
457            global_ref: true,
458            ..ChangeScopeRequest::default()
459        };
460        let scope = ChangeScope::resolve(request, &config, &[]).expect("global scope");
461        assert!(scope.scope_reason().is_some());
462        assert!(scope.packages().is_none());
463    }
464
465    #[test]
466    fn a_changed_file_set_wins_over_the_map() {
467        let temp = tempfile::tempdir().expect("tempdir");
468        let config = unknown_workspace_map(temp.path());
469        let changed: FxHashSet<PathBuf> = std::iter::once(temp.path().join("a.ts")).collect();
470        let request = ChangeScopeRequest {
471            files: Some(&changed),
472            ..ChangeScopeRequest::default()
473        };
474        let scope = ChangeScope::resolve(request, &config, &[]).expect("file scope");
475        assert!(scope.scope_reason().is_some());
476        assert!(scope.contains(&temp.path().join("a.ts")));
477        assert!(!scope.contains(&temp.path().join("b.ts")));
478    }
479
480    /// A map that cannot apply as written stands down as a whole. The report
481    /// is then wider than asked, and the outcome says so.
482    #[test]
483    fn a_key_that_names_no_workspace_stands_the_map_down() {
484        let temp = tempfile::tempdir().expect("tempdir");
485        std::fs::create_dir_all(temp.path().join("packages/web")).expect("package");
486        let config = config_with_map(temp.path(), &[("packages/wbe", "HEAD")]);
487        let workspaces = [WorkspaceInfo {
488            root: temp.path().join("packages/web"),
489            name: "web".to_owned(),
490            is_internal_dependency: false,
491        }];
492        let scope = ChangeScope::resolve(ChangeScopeRequest::default(), &config, &workspaces)
493            .expect("an unknown key stands down");
494        assert!(scope.scope_reason().is_none());
495        assert!(scope.packages().is_none());
496        assert!(scope.package_baselines().is_empty());
497        let outcome = scope.request_outcome().expect("the map was read");
498        assert_eq!(outcome.status, fallow_output::RequestStatus::NotApplied);
499        assert_eq!(outcome.reason.as_deref(), Some("unknown-workspace"));
500        let message = scope.stand_down_message().expect("a stand-down sentence");
501        assert!(
502            message.contains("did you mean 'packages/web'?"),
503            "{message}"
504        );
505    }
506
507    #[test]
508    fn a_malformed_key_or_ref_is_invalid_input() {
509        let temp = tempfile::tempdir().expect("tempdir");
510        for map in [
511            [("./packages/web", "HEAD")],
512            [("packages/web", "-malformed")],
513        ] {
514            std::fs::create_dir_all(temp.path().join("packages/web")).expect("package");
515            let config = config_with_map(temp.path(), &map);
516            let workspaces = [WorkspaceInfo {
517                root: temp.path().join("packages/web"),
518                name: "web".to_owned(),
519                is_internal_dependency: false,
520            }];
521            assert!(
522                ChangeScope::resolve(ChangeScopeRequest::default(), &config, &workspaces).is_err(),
523                "{map:?}"
524            );
525        }
526    }
527
528    #[test]
529    fn the_run_memo_resolves_the_map_once() {
530        let temp = tempfile::tempdir().expect("tempdir");
531        let config = unknown_workspace_map(temp.path());
532        let cache = PackageBaselineCache::new();
533        assert!(!cache.is_resolved());
534        assert!(cache.request_outcome().is_none());
535        let request = ChangeScopeRequest {
536            cache: Some(&cache),
537            ..ChangeScopeRequest::default()
538        };
539        ChangeScope::resolve(request, &config, &[]).expect("first resolution");
540        assert!(cache.is_resolved());
541        let later_config = config_with_map(temp.path(), &[]);
542        let later = ChangeScope::resolve(
543            ChangeScopeRequest {
544                cache: Some(&cache),
545                ..ChangeScopeRequest::default()
546            },
547            &config,
548            &[],
549        )
550        .expect("memo hit");
551        assert_eq!(later.request_outcome(), cache.request_outcome().as_ref());
552        let empty = ChangeScope::resolve(ChangeScopeRequest::default(), &later_config, &[])
553            .expect("no map, full scope");
554        assert!(empty.scope_reason().is_none());
555        assert!(empty.request_outcome().is_none());
556    }
557}