Skip to main content

fallow_api/
analysis_context.rs

1//! Shared programmatic analysis context resolution.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use fallow_config::WorkspaceInfo;
8use fallow_engine::workspace_scope::{WorkspaceScopeError, WorkspaceScopeMode};
9use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
10use fallow_types::path_util::is_absolute_path_any_platform;
11use rustc_hash::FxHashSet;
12
13use crate::{AnalysisOptions, ProgrammaticError};
14
15type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
16
17/// Resolved common programmatic analysis context.
18///
19/// This owns validation, root/config/diff resolution, production overrides,
20/// workspace scope, and the per-call thread pool shared by programmatic
21/// analysis families. API runtimes and engine-backed runners use it directly.
22pub struct ProgrammaticAnalysisContext {
23    pub(crate) root: PathBuf,
24    pub(crate) config_path: Option<PathBuf>,
25    pub(crate) allow_remote_extends: bool,
26    pub(crate) no_cache: bool,
27    pub(crate) threads: usize,
28    pub(crate) pool: rayon::ThreadPool,
29    pub(crate) diff: Option<DiffIndex>,
30    pub(crate) production_override: Option<bool>,
31    pub(crate) changed_since: Option<String>,
32    pub(crate) workspace: Option<Vec<String>>,
33    pub(crate) changed_workspaces: Option<String>,
34    pub(crate) workspace_roots: Option<Vec<PathBuf>>,
35    pub(crate) explain: bool,
36    pub(crate) cancellation: Option<Arc<AtomicBool>>,
37}
38
39/// Resolve common programmatic analysis options once for a concrete runtime.
40///
41/// # Errors
42///
43/// Returns a structured programmatic error for invalid roots, configs, thread
44/// counts, workspace scopes, or explicit diff files.
45pub fn resolve_programmatic_analysis_context(
46    options: &AnalysisOptions,
47) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
48    resolve_programmatic_analysis_context_inner(options, true)
49}
50
51pub fn resolve_programmatic_analysis_context_deferred_workspace(
52    options: &AnalysisOptions,
53) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
54    resolve_programmatic_analysis_context_inner(options, false)
55}
56
57fn resolve_programmatic_analysis_context_inner(
58    options: &AnalysisOptions,
59    resolve_workspace: bool,
60) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
61    validate_analysis_option_shape(options)?;
62    let root = resolve_analysis_root(options.root.as_deref())?;
63    validate_analysis_config_path(options.config_path.as_deref())?;
64    let threads = options.threads.unwrap_or_else(default_threads);
65    let pool = fallow_engine::thread_pool::worker_pool_builder(threads)
66        .build()
67        .map_err(|err| {
68            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
69                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
70                .with_context("analysis.threads")
71        })?;
72    let diff = options
73        .diff_file
74        .as_deref()
75        .map(|path| load_explicit_diff_file(path, &root))
76        .transpose()?;
77    let workspace_roots = if resolve_workspace {
78        resolve_workspace_scope(
79            &root,
80            options.workspace.as_deref(),
81            options.changed_workspaces.as_deref(),
82        )?
83    } else {
84        None
85    };
86    Ok(ProgrammaticAnalysisContext {
87        root,
88        config_path: options.config_path.clone(),
89        allow_remote_extends: options.allow_remote_extends,
90        no_cache: options.no_cache,
91        threads,
92        pool,
93        diff,
94        production_override: options
95            .production_override
96            .or_else(|| options.production.then_some(true)),
97        changed_since: options.changed_since.clone(),
98        workspace: options.workspace.clone(),
99        changed_workspaces: options.changed_workspaces.clone(),
100        workspace_roots,
101        explain: options.explain,
102        cancellation: options.cancellation.clone(),
103    })
104}
105
106fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
107    if options.threads == Some(0) {
108        return Err(
109            ProgrammaticError::new("`threads` must be greater than 0", 2)
110                .with_code("FALLOW_INVALID_THREADS")
111                .with_context("analysis.threads"),
112        );
113    }
114    if options.workspace.is_some() && options.changed_workspaces.is_some() {
115        return Err(ProgrammaticError::new(
116            "`workspace` and `changed_workspaces` are mutually exclusive",
117            2,
118        )
119        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
120        .with_context("analysis.workspace"));
121    }
122    Ok(())
123}
124
125pub fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
126    let root = match root {
127        Some(root) => root.to_path_buf(),
128        None => std::env::current_dir().map_err(|err| {
129            ProgrammaticError::new(
130                format!("failed to resolve current working directory: {err}"),
131                2,
132            )
133            .with_code("FALLOW_CWD_UNAVAILABLE")
134            .with_context("analysis.root")
135        })?,
136    };
137    fallow_engine::validate::validate_root(&root).map_err(|err| {
138        ProgrammaticError::new(err, 2)
139            .with_code("FALLOW_INVALID_ROOT")
140            .with_context("analysis.root")
141    })
142}
143
144pub fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
145    if let Some(config_path) = config_path
146        && !config_path.exists()
147    {
148        return Err(ProgrammaticError::new(
149            format!("config file does not exist: {}", config_path.display()),
150            2,
151        )
152        .with_code("FALLOW_INVALID_CONFIG_PATH")
153        .with_context("analysis.configPath"));
154    }
155    Ok(())
156}
157
158impl ProgrammaticAnalysisContext {
159    /// Run work inside the per-call Rayon pool.
160    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
161        self.pool.install(f)
162    }
163
164    /// Resolved analysis root.
165    #[must_use]
166    pub fn root(&self) -> &Path {
167        &self.root
168    }
169
170    /// Config path supplied by the caller, if any.
171    #[must_use]
172    pub fn config_path(&self) -> &Option<PathBuf> {
173        &self.config_path
174    }
175
176    /// Whether this call permits remote config inheritance.
177    #[must_use]
178    pub const fn allow_remote_extends(&self) -> bool {
179        self.allow_remote_extends
180    }
181
182    /// Whether parser cache use is disabled for this call.
183    #[must_use]
184    pub const fn no_cache(&self) -> bool {
185        self.no_cache
186    }
187
188    /// Effective parser thread count for this call.
189    #[must_use]
190    pub const fn threads(&self) -> usize {
191        self.threads
192    }
193
194    /// Parsed explicit diff file, if supplied.
195    #[must_use]
196    pub const fn diff_index(&self) -> Option<&DiffIndex> {
197        self.diff.as_ref()
198    }
199
200    /// Explicit production override supplied by the caller.
201    #[must_use]
202    pub const fn production_override(&self) -> Option<bool> {
203        self.production_override
204    }
205
206    /// Git ref used to scope changed files.
207    #[must_use]
208    pub fn changed_since(&self) -> Option<&str> {
209        self.changed_since.as_deref()
210    }
211
212    /// Workspace filter patterns supplied by the caller.
213    #[must_use]
214    pub fn workspace(&self) -> Option<&[String]> {
215        self.workspace.as_deref()
216    }
217
218    /// Git ref used to scope changed workspaces.
219    #[must_use]
220    pub fn changed_workspaces(&self) -> Option<&str> {
221        self.changed_workspaces.as_deref()
222    }
223
224    /// Whether API JSON should include explanatory metadata.
225    #[must_use]
226    pub const fn explain_enabled(&self) -> bool {
227        self.explain
228    }
229
230    /// The caller's cancellation token for this analysis, if it supplied one.
231    #[must_use]
232    pub fn cancellation(&self) -> Option<&Arc<AtomicBool>> {
233        self.cancellation.as_ref()
234    }
235
236    /// Whether the caller has asked this analysis to stop.
237    #[must_use]
238    pub fn is_cancelled(&self) -> bool {
239        self.cancellation
240            .as_ref()
241            .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
242    }
243
244    /// Stop the analysis at a stage boundary once the caller has cancelled it.
245    ///
246    /// `stage` names the work that has not been started, so the error says how
247    /// far the run got rather than only that it was stopped.
248    ///
249    /// # Errors
250    ///
251    /// Returns a `FALLOW_CANCELLED` programmatic error when the caller's token
252    /// is set. Cancellation is always an error, never an empty success: an
253    /// empty report reads downstream as a clean project.
254    pub fn ensure_not_cancelled(&self, stage: &str) -> ProgrammaticResult<()> {
255        if self.is_cancelled() {
256            return Err(cancelled_error(stage));
257        }
258        Ok(())
259    }
260}
261
262/// Stop before any work starts when the caller's token is already set.
263///
264/// Runtimes that never build a [`ProgrammaticAnalysisContext`] read the token
265/// straight off the options with this.
266///
267/// # Errors
268///
269/// Returns a `FALLOW_CANCELLED` programmatic error when the token is set.
270pub fn ensure_options_not_cancelled(
271    options: &AnalysisOptions,
272    stage: &str,
273) -> ProgrammaticResult<()> {
274    if options
275        .cancellation
276        .as_ref()
277        .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
278    {
279        return Err(cancelled_error(stage));
280    }
281    Ok(())
282}
283
284/// The single `FALLOW_CANCELLED` error shape for the programmatic API.
285///
286/// `stage` names the work the run never started, so the error says how far it
287/// got and not only that it stopped.
288#[must_use]
289pub fn cancelled_error(stage: &str) -> ProgrammaticError {
290    cancelled_error_message(&format!("analysis was cancelled before {stage}"))
291}
292
293/// A `FALLOW_CANCELLED` error carrying a message a lower layer already built.
294#[must_use]
295pub fn cancelled_error_message(message: &str) -> ProgrammaticError {
296    ProgrammaticError::new(message, 2)
297        .with_code("FALLOW_CANCELLED")
298        .with_context("analysis.cancellation")
299}
300
301fn default_threads() -> usize {
302    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
303}
304
305fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
306    if path == Path::new("-") {
307        return Err(ProgrammaticError::new(
308            "`diff_file` does not support stdin; pass a file path",
309            2,
310        )
311        .with_code("FALLOW_INVALID_DIFF_FILE")
312        .with_context("analysis.diffFile"));
313    }
314    let abs = if is_absolute_path_any_platform(path) {
315        path.to_path_buf()
316    } else {
317        root.join(path)
318    };
319    let meta = std::fs::metadata(&abs).map_err(|err| {
320        ProgrammaticError::new(
321            format!(
322                "diff file does not exist or cannot be read: {} ({err})",
323                abs.display()
324            ),
325            2,
326        )
327        .with_code("FALLOW_INVALID_DIFF_FILE")
328        .with_context("analysis.diffFile")
329    })?;
330    if !meta.is_file() {
331        return Err(ProgrammaticError::new(
332            format!("diff path is not a file: {}", abs.display()),
333            2,
334        )
335        .with_code("FALLOW_INVALID_DIFF_FILE")
336        .with_context("analysis.diffFile"));
337    }
338    if meta.len() > MAX_DIFF_BYTES {
339        return Err(ProgrammaticError::new(
340            format!(
341                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
342                meta.len(),
343                abs.display()
344            ),
345            2,
346        )
347        .with_code("FALLOW_INVALID_DIFF_FILE")
348        .with_context("analysis.diffFile"));
349    }
350    let text = std::fs::read_to_string(&abs).map_err(|err| {
351        ProgrammaticError::new(
352            format!("failed to read diff file {}: {err}", abs.display()),
353            2,
354        )
355        .with_code("FALLOW_INVALID_DIFF_FILE")
356        .with_context("analysis.diffFile")
357    })?;
358    Ok(DiffIndex::from_unified_diff(&text))
359}
360
361pub fn changed_files_for_run(
362    resolved: &ProgrammaticAnalysisContext,
363) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
364    let Some(git_ref) = resolved.changed_since.as_deref() else {
365        return Ok(None);
366    };
367    fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
368        .map(Some)
369        .map_err(|err| {
370            ProgrammaticError::new(
371                format!(
372                    "failed to resolve changed files for ref `{git_ref}`: {}",
373                    err.describe()
374                ),
375                2,
376            )
377            .with_code("FALLOW_CHANGED_FILES_FAILED")
378            .with_context("analysis.changedSince")
379        })
380}
381
382pub fn workspace_roots_for_session(
383    resolved: &ProgrammaticAnalysisContext,
384    workspaces: &[WorkspaceInfo],
385) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
386    resolve_workspace_scope_from_workspaces(
387        &resolved.root,
388        resolved.workspace.as_deref(),
389        resolved.changed_workspaces.as_deref(),
390        workspaces,
391    )
392}
393
394fn resolve_workspace_scope(
395    root: &Path,
396    workspace: Option<&[String]>,
397    changed_workspaces: Option<&str>,
398) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
399    fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
400        root,
401        workspace,
402        changed_workspaces,
403    )
404    .map_err(map_workspace_scope_error)
405}
406
407fn resolve_workspace_scope_from_workspaces(
408    root: &Path,
409    workspace: Option<&[String]>,
410    changed_workspaces: Option<&str>,
411    workspaces: &[WorkspaceInfo],
412) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
413    fallow_engine::workspace_scope::resolve_workspace_scope_roots(
414        root,
415        workspace,
416        changed_workspaces,
417        workspaces,
418    )
419    .map_err(map_workspace_scope_error)
420}
421
422#[cfg(test)]
423pub fn resolve_workspace_filters(
424    root: &Path,
425    patterns: &[String],
426) -> ProgrammaticResult<Vec<PathBuf>> {
427    fallow_engine::workspace_scope::resolve_workspace_filter_roots_for_project(root, patterns)
428        .map_err(map_workspace_scope_error)
429}
430
431fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
432    match err {
433        WorkspaceScopeError::NoWorkspaces {
434            mode,
435            patterns,
436            git_ref,
437        } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
438        WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
439            format!("invalid `workspace` pattern '{pattern}': {message}"),
440            2,
441        )
442        .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
443        .with_context("analysis.workspace"),
444        WorkspaceScopeError::UnmatchedPatterns {
445            patterns,
446            available,
447        } => ProgrammaticError::new(
448            format!(
449                "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
450                if patterns.len() == 1 { "" } else { "s" },
451                quote_owned_patterns(&patterns),
452            ),
453            2,
454        )
455        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
456        .with_context("analysis.workspace"),
457        WorkspaceScopeError::EmptyAfterExclusions { .. } => {
458            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
459                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
460                .with_context("analysis.workspace")
461        }
462        WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
463            ProgrammaticError::new(
464                format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
465                2,
466            )
467            .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
468            .with_context("analysis.changedWorkspaces")
469        }
470        WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
471            "`workspace` and `changed_workspaces` are mutually exclusive",
472            2,
473        )
474        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
475        .with_context("analysis.workspace"),
476    }
477}
478
479fn map_no_workspaces_error(
480    mode: WorkspaceScopeMode,
481    patterns: &[String],
482    git_ref: Option<&str>,
483) -> ProgrammaticError {
484    match mode {
485        WorkspaceScopeMode::Workspace => ProgrammaticError::new(
486            format!(
487                "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
488                quote_owned_patterns(patterns)
489            ),
490            2,
491        )
492        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
493        .with_context("analysis.workspace"),
494        WorkspaceScopeMode::ChangedWorkspaces => {
495            let git_ref = git_ref.unwrap_or_default();
496            ProgrammaticError::new(
497                format!(
498                    "`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\"."
499                ),
500                2,
501            )
502            .with_code("FALLOW_WORKSPACES_NOT_FOUND")
503            .with_context("analysis.changedWorkspaces")
504        }
505    }
506}
507
508fn quote_owned_patterns(patterns: &[String]) -> String {
509    patterns
510        .iter()
511        .map(|pattern| format!("'{pattern}'"))
512        .collect::<Vec<_>>()
513        .join(", ")
514}
515
516#[cfg(test)]
517mod tests {
518    use std::process::Command;
519
520    use crate::AnalysisOptions;
521
522    const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
523    const STACK_PROBE_TEST: &str =
524        "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
525
526    // A stack overflow aborts the whole process, so the probe re-runs this
527    // test binary as a child and asserts on its exit status; the same pattern
528    // guards the CLI global pool in crates/cli/src/rayon_pool.rs. The child
529    // drops RUST_MIN_STACK (pinned to 16 MiB in .cargo/config.toml, and
530    // inherited by default-sized rayon workers) so the probe still fails if
531    // the pool loses its explicit stack_size.
532    #[test]
533    fn programmatic_pool_survives_deep_worker_stack_probe() {
534        if std::env::var_os(STACK_PROBE_ENV).is_some() {
535            run_stack_probe_child();
536            return;
537        }
538
539        let current_exe = std::env::current_exe().expect("current test binary should be known");
540        let output = Command::new(current_exe)
541            .arg("--exact")
542            .arg(STACK_PROBE_TEST)
543            .arg("--nocapture")
544            .env(STACK_PROBE_ENV, "1")
545            .env_remove("RUST_MIN_STACK")
546            .output()
547            .expect("stack probe child should start");
548
549        assert!(
550            output.status.success(),
551            "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
552            output.status.code(),
553            String::from_utf8_lossy(&output.stdout),
554            String::from_utf8_lossy(&output.stderr)
555        );
556    }
557
558    fn run_stack_probe_child() {
559        let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
560        let options = AnalysisOptions {
561            root: Some(root.path().to_path_buf()),
562            threads: Some(1),
563            ..AnalysisOptions::default()
564        };
565        let context = super::resolve_programmatic_analysis_context(&options)
566            .expect("stack probe context should resolve");
567        assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
568    }
569
570    #[inline(never)]
571    fn consume_stack(depth: usize) -> usize {
572        let frame = [0_u8; 2048];
573        std::hint::black_box(&frame);
574        if depth == 0 {
575            usize::from(frame[0])
576        } else {
577            1 + consume_stack(depth - 1)
578        }
579    }
580}