Skip to main content

fallow_api/
analysis_context.rs

1//! Shared programmatic analysis context resolution.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex, OnceLock};
6
7use fallow_config::{ResolvedConfig, WorkspaceInfo};
8use fallow_engine::change_scope::{
9    ChangeScope, ChangeScopeOwner, ChangeScopeRequest, PackageBaselineCache,
10};
11use fallow_engine::workspace_scope::{WorkspaceScopeError, WorkspaceScopeMode};
12use fallow_output::{DiffIndex, MAX_DIFF_BYTES, RequestName, RequestOutcome, RequestOutcomes};
13use fallow_types::path_util::is_absolute_path_any_platform;
14use rustc_hash::FxHashSet;
15
16use crate::{AnalysisOptions, ProgrammaticError};
17
18type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
19
20/// Resolved common programmatic analysis context.
21///
22/// This owns validation, root/config/diff resolution, production overrides,
23/// workspace scope, and the per-call thread pool shared by programmatic
24/// analysis families. API runtimes and engine-backed runners use it directly.
25pub struct ProgrammaticAnalysisContext {
26    pub(crate) root: PathBuf,
27    pub(crate) config_path: Option<PathBuf>,
28    pub(crate) allow_remote_extends: bool,
29    pub(crate) no_cache: bool,
30    pub(crate) threads: usize,
31    pub(crate) pool: rayon::ThreadPool,
32    pub(crate) diff: Option<DiffIndex>,
33    /// What became of the diff request, for the envelope's `request_outcomes`.
34    pub(crate) diff_request: Option<RequestOutcome>,
35    pub(crate) production_override: Option<bool>,
36    /// The changed-since ref the call narrows by: the caller's own, or the
37    /// ambient one when it resolved. `None` when an ambient ref stood down.
38    pub(crate) changed_since: Option<String>,
39    /// What became of the changed-since request, set once it resolved or
40    /// stood down.
41    pub(crate) changed_since_request: OnceLock<RequestOutcome>,
42    /// The changed files of the resolved ref, normalized like the CLI's.
43    pub(crate) changed_since_files: OnceLock<FxHashSet<PathBuf>>,
44    /// Who owns the change scope of the call's analyses. Audit owns it, so
45    /// its sections never read `workspaces.changedSince`.
46    pub(crate) change_scope_owner: ChangeScopeOwner,
47    /// The caller turned `workspaces.changedSince` off for this call.
48    pub(crate) no_package_baselines: bool,
49    /// The package map as the call's first analysis resolved it. Later
50    /// analyses of the call reuse it, and its outcome is the call's
51    /// `package-baselines` request outcome.
52    pub(crate) package_baselines: PackageBaselineCache,
53    /// The changed files the call's analyses kept, over every analysis that
54    /// measured: the `scope_size` of the `changed-since` entry.
55    pub(crate) changed_since_analyzed: Mutex<Option<FxHashSet<PathBuf>>>,
56    pub(crate) workspace: Option<Vec<String>>,
57    pub(crate) changed_workspaces: Option<String>,
58    pub(crate) workspace_roots: Option<Vec<PathBuf>>,
59    pub(crate) explain: bool,
60    pub(crate) cancellation: Option<Arc<AtomicBool>>,
61}
62
63/// Resolve common programmatic analysis options once for a concrete runtime.
64///
65/// # Errors
66///
67/// Returns a structured programmatic error for invalid roots, configs, thread
68/// counts, workspace scopes, or explicit diff files.
69pub fn resolve_programmatic_analysis_context(
70    options: &AnalysisOptions,
71) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
72    resolve_programmatic_analysis_context_inner(options, true)
73}
74
75pub fn resolve_programmatic_analysis_context_deferred_workspace(
76    options: &AnalysisOptions,
77) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
78    resolve_programmatic_analysis_context_inner(options, false)
79}
80
81fn resolve_programmatic_analysis_context_inner(
82    options: &AnalysisOptions,
83    resolve_workspace: bool,
84) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
85    validate_analysis_option_shape(options)?;
86    let root = resolve_analysis_root(options.root.as_deref())?;
87    validate_analysis_config_path(options.config_path.as_deref())?;
88    let threads = options.threads.unwrap_or_else(default_threads);
89    let pool = fallow_engine::thread_pool::worker_pool_builder(threads)
90        .build()
91        .map_err(|err| {
92            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
93                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
94                .with_context("analysis.threads")
95        })?;
96    let (diff, diff_request) = resolve_diff(options, &root)?;
97    let changed_since_request = OnceLock::new();
98    let changed_since_files = OnceLock::new();
99    let changed_since =
100        resolve_changed_since(options, &root, &changed_since_request, &changed_since_files);
101    let workspace_roots = if resolve_workspace {
102        resolve_workspace_scope(
103            &root,
104            options.workspace.as_deref(),
105            options.changed_workspaces.as_deref(),
106        )?
107    } else {
108        None
109    };
110    Ok(ProgrammaticAnalysisContext {
111        root,
112        config_path: options.config_path.clone(),
113        allow_remote_extends: options.allow_remote_extends,
114        no_cache: options.no_cache,
115        threads,
116        pool,
117        diff,
118        diff_request,
119        production_override: options
120            .production_override
121            .or_else(|| options.production.then_some(true)),
122        changed_since,
123        changed_since_request,
124        changed_since_files,
125        change_scope_owner: ChangeScopeOwner::Run,
126        no_package_baselines: options.no_package_baselines,
127        package_baselines: PackageBaselineCache::new(),
128        changed_since_analyzed: Mutex::new(None),
129        workspace: options.workspace.clone(),
130        changed_workspaces: options.changed_workspaces.clone(),
131        workspace_roots,
132        explain: options.explain,
133        cancellation: options.cancellation.clone(),
134    })
135}
136
137fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
138    if options.threads == Some(0) {
139        return Err(
140            ProgrammaticError::new("`threads` must be greater than 0", 2)
141                .with_code("FALLOW_INVALID_THREADS")
142                .with_context("analysis.threads"),
143        );
144    }
145    if options.workspace.is_some() && options.changed_workspaces.is_some() {
146        return Err(ProgrammaticError::new(
147            "`workspace` and `changed_workspaces` are mutually exclusive",
148            2,
149        )
150        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
151        .with_context("analysis.workspace"));
152    }
153    Ok(())
154}
155
156pub fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
157    let root = match root {
158        Some(root) => root.to_path_buf(),
159        None => std::env::current_dir().map_err(|err| {
160            ProgrammaticError::new(
161                format!("failed to resolve current working directory: {err}"),
162                2,
163            )
164            .with_code("FALLOW_CWD_UNAVAILABLE")
165            .with_context("analysis.root")
166        })?,
167    };
168    fallow_engine::validate::validate_root(&root).map_err(|err| {
169        ProgrammaticError::new(err, 2)
170            .with_code("FALLOW_INVALID_ROOT")
171            .with_context("analysis.root")
172    })
173}
174
175pub fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
176    if let Some(config_path) = config_path
177        && !config_path.exists()
178    {
179        return Err(ProgrammaticError::new(
180            format!("config file does not exist: {}", config_path.display()),
181            2,
182        )
183        .with_code("FALLOW_INVALID_CONFIG_PATH")
184        .with_context("analysis.configPath"));
185    }
186    Ok(())
187}
188
189impl ProgrammaticAnalysisContext {
190    /// Run work inside the per-call Rayon pool.
191    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
192        self.pool.install(f)
193    }
194
195    /// Resolved analysis root.
196    #[must_use]
197    pub fn root(&self) -> &Path {
198        &self.root
199    }
200
201    /// Config path supplied by the caller, if any.
202    #[must_use]
203    pub fn config_path(&self) -> &Option<PathBuf> {
204        &self.config_path
205    }
206
207    /// Whether this call permits remote config inheritance.
208    #[must_use]
209    pub const fn allow_remote_extends(&self) -> bool {
210        self.allow_remote_extends
211    }
212
213    /// Whether parser cache use is disabled for this call.
214    #[must_use]
215    pub const fn no_cache(&self) -> bool {
216        self.no_cache
217    }
218
219    /// Effective parser thread count for this call.
220    #[must_use]
221    pub const fn threads(&self) -> usize {
222        self.threads
223    }
224
225    /// Parsed diff for this call, explicit or ambient, if one applied.
226    #[must_use]
227    pub const fn diff_index(&self) -> Option<&DiffIndex> {
228        self.diff.as_ref()
229    }
230
231    /// The call's `request_outcomes`, or `None` when it was asked for nothing.
232    ///
233    /// Carries the `diff-filter` entry, which is the one request this context
234    /// resolves and can stand down. Same object as the CLI publishes for the
235    /// same diff.
236    #[must_use]
237    pub fn request_outcomes(&self) -> Option<RequestOutcomes> {
238        let mut requests = RequestOutcomes::new();
239        requests.insert_if(RequestName::ChangedSince, self.changed_since_outcome());
240        requests.insert_if(RequestName::DiffFilter, self.diff_request.clone());
241        requests.insert_if(
242            RequestName::PackageBaselines,
243            self.package_baselines.request_outcome(),
244        );
245        requests.into_option()
246    }
247
248    /// The `changed-since` entry, with the measured scope when the ref applied
249    /// and an analysis measured it, as the CLI publishes it.
250    fn changed_since_outcome(&self) -> Option<RequestOutcome> {
251        let outcome = self.changed_since_request.get()?.clone();
252        let size = self
253            .changed_since_analyzed
254            .lock()
255            .ok()
256            .and_then(|analyzed| analyzed.as_ref().map(|files| files.len() as u64));
257        Some(match size {
258            Some(size) if outcome.status == fallow_output::RequestStatus::Applied => {
259                RequestOutcome {
260                    scope_size: Some(size),
261                    ..outcome
262                }
263            }
264            _ => outcome,
265        })
266    }
267
268    /// Add the changed files an analysis kept to the call's analyzed changed
269    /// files. Does nothing when no ref resolved.
270    pub(crate) fn measure_changed_since_scope<'a>(
271        &self,
272        analyzed: impl IntoIterator<Item = &'a Path>,
273    ) {
274        let Some(changed) = self.changed_since_files.get() else {
275            return;
276        };
277        let Ok(mut union) = self.changed_since_analyzed.lock() else {
278            return;
279        };
280        union.get_or_insert_with(FxHashSet::default).extend(
281            analyzed
282                .into_iter()
283                .map(dunce::simplified)
284                .filter(|path| changed.contains(*path))
285                .map(Path::to_path_buf),
286        );
287    }
288
289    /// Record the resolved changed files of the call's ref, and the `applied`
290    /// entry, once.
291    fn record_changed_since_applied(&self, git_ref: &str, files: &FxHashSet<PathBuf>) {
292        let _ = self.changed_since_files.set(
293            files
294                .iter()
295                .map(|path| dunce::simplified(path).to_path_buf())
296                .collect(),
297        );
298        let _ = self
299            .changed_since_request
300            .set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
301    }
302
303    /// Record that an engine runner narrowed by the call's ref, with the
304    /// changed files it kept. For a runner that resolves the ref itself.
305    pub(crate) fn record_changed_since_from_runner(&self, kept: Option<&[PathBuf]>) {
306        let (Some(git_ref), Some(kept)) = (self.changed_since.as_deref(), kept) else {
307            return;
308        };
309        if self.changed_since_files.get().is_none() {
310            let files: FxHashSet<PathBuf> = kept.iter().cloned().collect();
311            self.record_changed_since_applied(git_ref, &files);
312        }
313        self.measure_changed_since_scope(kept.iter().map(PathBuf::as_path));
314    }
315
316    /// Explicit production override supplied by the caller.
317    #[must_use]
318    pub const fn production_override(&self) -> Option<bool> {
319        self.production_override
320    }
321
322    /// Git ref used to scope changed files.
323    #[must_use]
324    pub fn changed_since(&self) -> Option<&str> {
325        self.changed_since.as_deref()
326    }
327
328    /// Hand the change scope of this call's analyses to the caller.
329    #[must_use]
330    pub(crate) const fn with_change_scope_owner(mut self, owner: ChangeScopeOwner) -> Self {
331        self.change_scope_owner = owner;
332        self
333    }
334
335    /// Resolve the change scope of one analysis in this call.
336    ///
337    /// `files` is the changed-file set of the analysis: the call's resolved
338    /// ref, or a set that the caller supplied. A requested ref that stood
339    /// down still suppresses the package map, as on the CLI.
340    pub(crate) fn change_scope(
341        &self,
342        files: Option<&FxHashSet<PathBuf>>,
343        config: &ResolvedConfig,
344        workspaces: &[WorkspaceInfo],
345    ) -> ProgrammaticResult<ChangeScope> {
346        let request = ChangeScopeRequest {
347            owner: self.change_scope_owner,
348            global_ref: self.changed_since.is_some() || self.changed_since_request.get().is_some(),
349            files,
350            cache: Some(&self.package_baselines),
351            no_package_baselines: self.no_package_baselines,
352        };
353        ChangeScope::resolve(request, config, workspaces).map_err(|err| {
354            ProgrammaticError::new(format!("workspace baseline error: {err}"), 2)
355                .with_code("FALLOW_PACKAGE_BASELINE_FAILED")
356                .with_context("analysis.workspaces.changedSince")
357        })
358    }
359
360    /// Workspace filter patterns supplied by the caller.
361    #[must_use]
362    pub fn workspace(&self) -> Option<&[String]> {
363        self.workspace.as_deref()
364    }
365
366    /// Git ref used to scope changed workspaces.
367    #[must_use]
368    pub fn changed_workspaces(&self) -> Option<&str> {
369        self.changed_workspaces.as_deref()
370    }
371
372    /// Whether API JSON should include explanatory metadata.
373    #[must_use]
374    pub const fn explain_enabled(&self) -> bool {
375        self.explain
376    }
377
378    /// The caller's cancellation token for this analysis, if it supplied one.
379    #[must_use]
380    pub fn cancellation(&self) -> Option<&Arc<AtomicBool>> {
381        self.cancellation.as_ref()
382    }
383
384    /// Whether the caller has asked this analysis to stop.
385    #[must_use]
386    pub fn is_cancelled(&self) -> bool {
387        self.cancellation
388            .as_ref()
389            .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
390    }
391
392    /// Stop the analysis at a stage boundary once the caller has cancelled it.
393    ///
394    /// `stage` names the work that has not been started, so the error says how
395    /// far the run got rather than only that it was stopped.
396    ///
397    /// # Errors
398    ///
399    /// Returns a `FALLOW_CANCELLED` programmatic error when the caller's token
400    /// is set. Cancellation is always an error, never an empty success: an
401    /// empty report reads downstream as a clean project.
402    pub fn ensure_not_cancelled(&self, stage: &str) -> ProgrammaticResult<()> {
403        if self.is_cancelled() {
404            return Err(cancelled_error(stage));
405        }
406        Ok(())
407    }
408}
409
410/// Stop before any work starts when the caller's token is already set.
411///
412/// Runtimes that never build a [`ProgrammaticAnalysisContext`] read the token
413/// straight off the options with this.
414///
415/// # Errors
416///
417/// Returns a `FALLOW_CANCELLED` programmatic error when the token is set.
418pub fn ensure_options_not_cancelled(
419    options: &AnalysisOptions,
420    stage: &str,
421) -> ProgrammaticResult<()> {
422    if options
423        .cancellation
424        .as_ref()
425        .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
426    {
427        return Err(cancelled_error(stage));
428    }
429    Ok(())
430}
431
432/// The single `FALLOW_CANCELLED` error shape for the programmatic API.
433///
434/// `stage` names the work the run never started, so the error says how far it
435/// got and not only that it stopped.
436#[must_use]
437pub fn cancelled_error(stage: &str) -> ProgrammaticError {
438    cancelled_error_message(&format!("analysis was cancelled before {stage}"))
439}
440
441/// A `FALLOW_CANCELLED` error carrying a message a lower layer already built.
442#[must_use]
443pub fn cancelled_error_message(message: &str) -> ProgrammaticError {
444    ProgrammaticError::new(message, 2)
445        .with_code("FALLOW_CANCELLED")
446        .with_context("analysis.cancellation")
447}
448
449fn default_threads() -> usize {
450    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
451}
452
453/// Resolve the call's diff from its two sources, which fail differently.
454///
455/// An explicit `diff_file` is the caller's own argument, so a bad file is a
456/// `FALLOW_INVALID_DIFF_FILE` error. An ambient `FALLOW_DIFF_FILE` comes from
457/// the environment the caller inherited, so a bad file stands down: no diff,
458/// full scope, and a `not-applied` outcome with the CLI's reason token and
459/// sentence. The source decides the behavior, never the text of an error.
460fn resolve_diff(
461    options: &AnalysisOptions,
462    root: &Path,
463) -> ProgrammaticResult<(Option<DiffIndex>, Option<RequestOutcome>)> {
464    if let Some(path) = options.diff_file.as_deref() {
465        let index = load_explicit_diff_file(path, root)?;
466        let request = diff_applied(format!("diffFile {}", path.display()), &index);
467        return Ok((Some(index), Some(request)));
468    }
469    let Some(path) = options.ambient_diff_file.as_deref() else {
470        return Ok((None, None));
471    };
472    Ok(load_ambient_diff_file(path, root))
473}
474
475/// Load and place an ambient diff the way the CLI loads `$FALLOW_DIFF_FILE`,
476/// with the same label, so both routes publish the same outcome object.
477fn load_ambient_diff_file(path: &Path, root: &Path) -> (Option<DiffIndex>, Option<RequestOutcome>) {
478    let abs = if path.is_absolute() {
479        path.to_path_buf()
480    } else {
481        root.join(path)
482    };
483    let label = format!("$FALLOW_DIFF_FILE {}", abs.display());
484    let placed = fallow_engine::diff_source::read_diff_file(&abs, &label).and_then(|text| {
485        fallow_engine::diff_source::place_diff(
486            DiffIndex::from_unified_diff(&text),
487            root,
488            &fallow_engine::diff_source::diff_base_candidates(root),
489            &label,
490        )
491    });
492    match placed {
493        Ok(index) => {
494            let request = diff_applied(label, &index);
495            (Some(index), Some(request))
496        }
497        Err(stand_down) => {
498            let (reason, message) = stand_down.into_parts();
499            let request =
500                RequestOutcome::not_applied(RequestName::DiffFilter, label, reason, message);
501            (None, Some(request))
502        }
503    }
504}
505
506/// An applied diff filter, sized in added lines like the CLI's.
507fn diff_applied(label: String, index: &DiffIndex) -> RequestOutcome {
508    RequestOutcome::applied_with_scope_size(
509        RequestName::DiffFilter,
510        label,
511        index.added_line_count() as u64,
512    )
513}
514
515fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
516    if path == Path::new("-") {
517        return Err(ProgrammaticError::new(
518            "`diff_file` does not support stdin; pass a file path",
519            2,
520        )
521        .with_code("FALLOW_INVALID_DIFF_FILE")
522        .with_context("analysis.diffFile"));
523    }
524    let abs = if is_absolute_path_any_platform(path) {
525        path.to_path_buf()
526    } else {
527        root.join(path)
528    };
529    let meta = std::fs::metadata(&abs).map_err(|err| {
530        ProgrammaticError::new(
531            format!(
532                "diff file does not exist or cannot be read: {} ({err})",
533                abs.display()
534            ),
535            2,
536        )
537        .with_code("FALLOW_INVALID_DIFF_FILE")
538        .with_context("analysis.diffFile")
539    })?;
540    if !meta.is_file() {
541        return Err(ProgrammaticError::new(
542            format!("diff path is not a file: {}", abs.display()),
543            2,
544        )
545        .with_code("FALLOW_INVALID_DIFF_FILE")
546        .with_context("analysis.diffFile"));
547    }
548    if meta.len() > MAX_DIFF_BYTES {
549        return Err(ProgrammaticError::new(
550            format!(
551                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
552                meta.len(),
553                abs.display()
554            ),
555            2,
556        )
557        .with_code("FALLOW_INVALID_DIFF_FILE")
558        .with_context("analysis.diffFile"));
559    }
560    let text = std::fs::read_to_string(&abs).map_err(|err| {
561        ProgrammaticError::new(
562            format!("failed to read diff file {}: {err}", abs.display()),
563            2,
564        )
565        .with_code("FALLOW_INVALID_DIFF_FILE")
566        .with_context("analysis.diffFile")
567    })?;
568    Ok(DiffIndex::from_unified_diff(&text))
569}
570
571/// Resolve the call's changed-since ref once, when it comes from the
572/// environment.
573///
574/// The two sources fail differently, like the two diff sources. An explicit
575/// `changed_since` is the caller's own argument, so a ref that does not
576/// resolve fails the call later, in [`changed_files_for_run`]. An ambient
577/// `FALLOW_CHANGED_SINCE` comes from the environment the caller inherited, so
578/// a ref that does not resolve stands down here: the call runs at full scope
579/// and publishes `not-applied` with the CLI's reason token and sentence.
580fn resolve_changed_since(
581    options: &AnalysisOptions,
582    root: &Path,
583    request: &OnceLock<RequestOutcome>,
584    files: &OnceLock<FxHashSet<PathBuf>>,
585) -> Option<String> {
586    if let Some(git_ref) = options.changed_since.as_deref() {
587        return Some(git_ref.to_owned());
588    }
589    let git_ref = options.ambient_changed_since.as_deref()?;
590    match fallow_engine::changed_files::changed_files(root, git_ref) {
591        Ok(changed) => {
592            let _ = files.set(
593                changed
594                    .iter()
595                    .map(|path| dunce::simplified(path).to_path_buf())
596                    .collect(),
597            );
598            let _ = request.set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
599            Some(git_ref.to_owned())
600        }
601        Err(err) => {
602            let _ = request.set(RequestOutcome::not_applied(
603                RequestName::ChangedSince,
604                git_ref,
605                err.reason(),
606                err.changed_since_message(git_ref),
607            ));
608            None
609        }
610    }
611}
612
613pub fn changed_files_for_run(
614    resolved: &ProgrammaticAnalysisContext,
615) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
616    let Some(git_ref) = resolved.changed_since.as_deref() else {
617        return Ok(None);
618    };
619    fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
620        .inspect(|files| resolved.record_changed_since_applied(git_ref, files))
621        .map(Some)
622        .map_err(|err| {
623            ProgrammaticError::new(
624                format!(
625                    "failed to resolve changed files for ref `{git_ref}`: {}",
626                    err.describe()
627                ),
628                2,
629            )
630            .with_code("FALLOW_CHANGED_FILES_FAILED")
631            .with_context("analysis.changedSince")
632        })
633}
634
635pub fn workspace_roots_for_session(
636    resolved: &ProgrammaticAnalysisContext,
637    workspaces: &[WorkspaceInfo],
638) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
639    resolve_workspace_scope_from_workspaces(
640        &resolved.root,
641        resolved.workspace.as_deref(),
642        resolved.changed_workspaces.as_deref(),
643        workspaces,
644    )
645}
646
647fn resolve_workspace_scope(
648    root: &Path,
649    workspace: Option<&[String]>,
650    changed_workspaces: Option<&str>,
651) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
652    fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
653        root,
654        workspace,
655        changed_workspaces,
656    )
657    .map_err(map_workspace_scope_error)
658}
659
660fn resolve_workspace_scope_from_workspaces(
661    root: &Path,
662    workspace: Option<&[String]>,
663    changed_workspaces: Option<&str>,
664    workspaces: &[WorkspaceInfo],
665) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
666    fallow_engine::workspace_scope::resolve_workspace_scope_roots(
667        root,
668        workspace,
669        changed_workspaces,
670        workspaces,
671    )
672    .map_err(map_workspace_scope_error)
673}
674
675fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
676    match err {
677        WorkspaceScopeError::NoWorkspaces {
678            mode,
679            patterns,
680            git_ref,
681        } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
682        WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
683            format!("invalid `workspace` pattern '{pattern}': {message}"),
684            2,
685        )
686        .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
687        .with_context("analysis.workspace"),
688        WorkspaceScopeError::UnmatchedPatterns {
689            patterns,
690            available,
691        } => ProgrammaticError::new(
692            format!(
693                "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
694                if patterns.len() == 1 { "" } else { "s" },
695                quote_owned_patterns(&patterns),
696            ),
697            2,
698        )
699        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
700        .with_context("analysis.workspace"),
701        WorkspaceScopeError::EmptyAfterExclusions { .. } => {
702            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
703                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
704                .with_context("analysis.workspace")
705        }
706        WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
707            ProgrammaticError::new(
708                format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
709                2,
710            )
711            .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
712            .with_context("analysis.changedWorkspaces")
713        }
714        WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
715            "`workspace` and `changed_workspaces` are mutually exclusive",
716            2,
717        )
718        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
719        .with_context("analysis.workspace"),
720    }
721}
722
723fn map_no_workspaces_error(
724    mode: WorkspaceScopeMode,
725    patterns: &[String],
726    git_ref: Option<&str>,
727) -> ProgrammaticError {
728    match mode {
729        WorkspaceScopeMode::Workspace => ProgrammaticError::new(
730            format!(
731                "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
732                quote_owned_patterns(patterns)
733            ),
734            2,
735        )
736        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
737        .with_context("analysis.workspace"),
738        WorkspaceScopeMode::ChangedWorkspaces => {
739            let git_ref = git_ref.unwrap_or_default();
740            ProgrammaticError::new(
741                format!(
742                    "`changed_workspaces` '{git_ref}' specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
743                ),
744                2,
745            )
746            .with_code("FALLOW_WORKSPACES_NOT_FOUND")
747            .with_context("analysis.changedWorkspaces")
748        }
749    }
750}
751
752fn quote_owned_patterns(patterns: &[String]) -> String {
753    patterns
754        .iter()
755        .map(|pattern| format!("'{pattern}'"))
756        .collect::<Vec<_>>()
757        .join(", ")
758}
759
760#[cfg(test)]
761mod tests {
762    use std::process::Command;
763
764    use crate::AnalysisOptions;
765
766    const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
767    const STACK_PROBE_TEST: &str =
768        "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
769
770    // A stack overflow aborts the whole process, so the probe re-runs this
771    // test binary as a child and asserts on its exit status; the same pattern
772    // guards the CLI global pool in crates/cli/src/rayon_pool.rs. The child
773    // drops RUST_MIN_STACK (pinned to 16 MiB in .cargo/config.toml, and
774    // inherited by default-sized rayon workers) so the probe still fails if
775    // the pool loses its explicit stack_size.
776    #[test]
777    fn programmatic_pool_survives_deep_worker_stack_probe() {
778        if std::env::var_os(STACK_PROBE_ENV).is_some() {
779            run_stack_probe_child();
780            return;
781        }
782
783        let current_exe = std::env::current_exe().expect("current test binary should be known");
784        let output = Command::new(current_exe)
785            .arg("--exact")
786            .arg(STACK_PROBE_TEST)
787            .arg("--nocapture")
788            .env(STACK_PROBE_ENV, "1")
789            .env_remove("RUST_MIN_STACK")
790            .output()
791            .expect("stack probe child should start");
792
793        assert!(
794            output.status.success(),
795            "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
796            output.status.code(),
797            String::from_utf8_lossy(&output.stdout),
798            String::from_utf8_lossy(&output.stderr)
799        );
800    }
801
802    fn run_stack_probe_child() {
803        let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
804        let options = AnalysisOptions {
805            root: Some(root.path().to_path_buf()),
806            threads: Some(1),
807            ..AnalysisOptions::default()
808        };
809        let context = super::resolve_programmatic_analysis_context(&options)
810            .expect("stack probe context should resolve");
811        assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
812    }
813
814    #[inline(never)]
815    fn consume_stack(depth: usize) -> usize {
816        let frame = [0_u8; 2048];
817        std::hint::black_box(&frame);
818        if depth == 0 {
819            usize::from(frame[0])
820        } else {
821            1 + consume_stack(depth - 1)
822        }
823    }
824}