mago 1.48.0

A comprehensive suite of PHP tooling inspired by Rust’s approach, providing parsing, linting, formatting, and more through a unified CLI and library interface.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
//! Issue processing services for reporting and fixing.
//!
//! This module provides the core services for handling analysis issues in the Mago CLI.
//! It contains two main processors that work together to provide flexible issue management:
//!
//! - [`IssueProcessor`]: Core processor for reporting and fixing issues
//! - [`BaselineIssueProcessor`]: Wrapper that adds baseline functionality to issue processing
//!
//! # Issue Processing Workflow
//!
//! The issue processing workflow follows these general steps:
//!
//! 1. **Issue Collection**: Issues are gathered from linter, analyzer, or other sources
//! 2. **Baseline Filtering** (optional): Issues are filtered against a baseline file
//! 3. **Processing**: Issues are either fixed automatically or reported to the user
//! 4. **Exit Code**: Appropriate exit code is returned based on results
//!
//! # Processor Types
//!
//! ## IssueProcessor
//!
//! The core processor handles two distinct modes:
//!
//! - **Report Mode**: Formats and displays issues using configurable reporters
//! - **Fix Mode**: Applies automatic fixes with configurable safety levels
//!
//! Report mode supports multiple output formats (rich, JSON, SARIF, GitHub Actions),
//! filtering, sorting, and severity-based exit codes.
//!
//! Fix mode applies code transformations in parallel with safety checks, optional
//! formatting, and dry-run preview capabilities.
//!
//! ## BaselineIssueProcessor
//!
//! Adds baseline management capabilities on top of IssueProcessor:
//!
//! - **Baseline Loading**: Reads existing baseline files to filter known issues
//! - **Baseline Generation**: Creates new baseline files from current issues
//! - **Baseline Verification**: Validates that baseline files are up-to-date
//! - **Synchronization Checks**: Optionally fails when baseline is out of sync
//!
//! # Safety Classifications
//!
//! Automatic fixes are categorized by safety:
//!
//! - **Safe**: Always applied with `--fix`
//! - **Potentially Unsafe**: Requires `--potentially-unsafe` flag
//! - **Unsafe**: Requires `--unsafe` flag
//!
//! This graduated approach allows teams to adopt automatic fixing incrementally,
//! starting with safe fixes and progressively enabling more aggressive transformations.

use std::borrow::Cow;
use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;

use mago_reporting::baseline::BaselineChangeEntry;
use serde::Serialize;

use clap::ColorChoice;
use mago_allocator::LocalArena;
use mago_database::file::FileId;
use mago_syntax::parser::parse_file_content_with_settings;
use mago_syntax::settings::ParserSettings;
use rayon::iter::IntoParallelIterator;
use rayon::iter::ParallelIterator;

use mago_database::Database;
use mago_database::DatabaseReader;
use mago_database::ReadDatabase;
use mago_database::change::ChangeLog;
use mago_database::file::File;
use mago_orchestrator::Orchestrator;
use mago_orchestrator::service::format::FileFormatStatus;
use mago_reporting::ColorChoice as ReportingColorChoice;
use mago_reporting::IssueCollection;
use mago_reporting::Level;
use mago_reporting::ReportingFormat;
use mago_reporting::ReportingTarget;
use mago_reporting::baseline::Baseline;
use mago_reporting::baseline::BaselineVariant;
use mago_reporting::reporter::Reporter;
use mago_reporting::reporter::ReporterConfig;
use mago_text_edit::ApplyResult;
use mago_text_edit::Safety;
use mago_text_edit::TextEditor;

use crate::baseline;
use crate::baseline::unserialize_baseline;
use crate::consts::ISSUE_URL;
use crate::error::Error;
use crate::utils;

/// Core issue processor for reporting and fixing analysis issues.
///
/// This processor is the main service for handling issues found during linting,
/// type analysis, or other code inspections. It supports two primary modes:
///
/// - **Report Mode** (`fix = false`): Issues are formatted and displayed using
///   the configured reporter with filtering, sorting, and severity-based exit codes.
/// - **Fix Mode** (`fix = true`): Issues with available automatic fixes are applied
///   to the source code with configurable safety levels and optional formatting.
///
/// # Lifecycle
///
/// The processor is typically created from `ReportingArgs` via the `get_processor` method
/// and consumed by calling [`process_issues`](Self::process_issues).
///
/// # Configuration
///
/// The processor's behavior is controlled by its fields, which are typically
/// populated from command-line arguments:
///
/// - Reporting options control output format, target, and filtering
/// - Fix options control safety levels, formatting, and dry-run mode
/// - Exit code behavior is determined by severity thresholds
#[derive(Debug)]
pub struct IssueProcessor {
    /// Filter to show only issues that have automatic fixes available.
    ///
    /// When `true`, issues without fixes are excluded from the report.
    /// This is useful for focusing on actionable issues that can be resolved
    /// automatically. Only applies in report mode.
    pub fixable_only: bool,

    /// Sort issues by severity, rule code, and location before reporting.
    ///
    /// When `true`, issues are sorted for better readability instead of being
    /// displayed in file order. Only applies in report mode.
    pub sort: bool,

    /// Enable automatic fix application mode.
    ///
    /// When `true`, switches from report mode to fix mode. Issues with automatic
    /// fixes will be applied to source files instead of being reported.
    pub fix: bool,

    /// Allow application of fixes marked as unsafe.
    ///
    /// Unsafe fixes may change code behavior or have unintended consequences.
    /// Setting this to `true` also enables potentially unsafe fixes.
    /// Only applies in fix mode.
    pub r#unsafe: bool,

    /// Allow application of fixes marked as potentially unsafe.
    ///
    /// Potentially unsafe fixes carry some risk but are generally safer than
    /// unsafe fixes. Only applies in fix mode.
    pub potentially_unsafe: bool,

    /// Automatically format files after applying fixes.
    ///
    /// When `true`, the formatter is run on any files modified by fixes to
    /// ensure consistent code style. Only applies in fix mode.
    pub format_after_fix: bool,

    /// Preview fixes without writing changes to disk.
    ///
    /// When `true`, displays diffs of proposed changes but doesn't modify files.
    /// Useful for reviewing fixes before applying them. Only applies in fix mode.
    pub dry_run: bool,

    /// Exit with failure if there are remaining unfixed issues after applying fixes.
    ///
    /// When `true`, the command exits with a non-zero status code if any issues
    /// could not be automatically fixed and require manual attention.
    /// Only applies in fix mode.
    pub fail_on_remaining: bool,

    /// Output target for issue reports (stdout or stderr).
    ///
    /// Determines where the reporter sends its output. Only applies in report mode.
    pub reporting_target: ReportingTarget,

    /// Output format for issue reports (rich, JSON, SARIF, etc.).
    ///
    /// Determines how issues are formatted for display. Only applies in report mode.
    pub reporting_format: ReportingFormat,

    /// Minimum severity level that causes the command to fail.
    ///
    /// If any issues at or above this level are found, the command exits with
    /// a failure code. Only applies in report mode.
    pub minimum_fail_level: Level,

    /// Minimum severity level to include in the report.
    ///
    /// Issues below this level are completely filtered out. When `None`, all
    /// issues are reported. Only applies in report mode.
    pub minimum_report_level: Option<Level>,

    /// Retain only issues with the specified code(s).
    ///
    /// When non-empty, only issues with codes matching one of the specified
    /// values are reported. All other issues are filtered out. This is the
    /// inverse of ignore filtering. Only applies in report mode.
    pub retain_code: Vec<String>,

    /// Color output choice for diffs and reports.
    ///
    /// Controls whether colored output is used in diffs (fix mode) and reports
    /// (report mode).
    pub color_choice: ColorChoice,

    /// Editor URL template for OSC 8 terminal hyperlinks on file paths.
    ///
    /// When set, file paths in diagnostic output become clickable links in
    /// terminals that support OSC 8 hyperlinks.
    pub editor_url: Option<String>,

    pub scoped_analysis: bool,
}

#[derive(Serialize)]
struct VerifyBaselineOutput<'a> {
    new_issues: &'a [BaselineChangeEntry],
    removed_issues: &'a [BaselineChangeEntry],
}

/// Baseline-aware issue processor for incremental issue adoption.
///
/// This processor wraps [`IssueProcessor`] with baseline management capabilities,
/// enabling teams to establish a snapshot of existing issues and focus on preventing
/// new issues from being introduced.
///
/// # Baseline Workflow
///
/// The baseline functionality supports three primary operations:
///
/// 1. **Filtering** (default): Filter reported issues against an existing baseline,
///    suppressing known issues and reporting only new ones.
/// 2. **Generation** (`generate_baseline = true`): Create or update a baseline file
///    from the current set of issues.
/// 3. **Verification** (`verify_baseline = true`): Check if the baseline is up-to-date
///    with current issues, failing if there are discrepancies.
///
/// # Incremental Adoption
///
/// Baselines are particularly useful when introducing linting or analysis to existing
/// codebases. Teams can:
///
/// - Generate a baseline of all existing issues
/// - Focus on preventing new issues in code reviews
/// - Gradually fix old issues and regenerate the baseline
/// - Use `--verify-baseline` in CI to ensure baselines stay current
///
/// # Lifecycle
///
/// The processor is typically created from `BaselineReportingArgs` via the `get_processor`
/// method and consumed by calling [`process_issues`](Self::process_issues).
#[derive(Debug)]
pub struct BaselineIssueProcessor {
    /// Optional path to the baseline file.
    ///
    /// Can be specified via CLI argument (`--baseline`) or configuration file.
    /// Uses `Cow` to avoid cloning paths from configuration while allowing
    /// owned paths from CLI arguments.
    ///
    /// When `None`, baseline operations are disabled and issues are processed
    /// without filtering.
    pub baseline_path: Option<Cow<'static, Path>>,

    /// Generate a new baseline file from current issues.
    ///
    /// When `true`, instead of processing issues normally, generates a baseline
    /// file and exits. Requires `baseline_path` to be set.
    pub generate_baseline: bool,

    /// Create a backup of the existing baseline before regenerating.
    ///
    /// When `true` and `generate_baseline` is enabled, the existing baseline
    /// file (if any) is renamed with a `.bkp` extension before the new baseline
    /// is written. Only applies when generating baselines.
    pub backup_baseline: bool,

    /// Verify that the baseline is synchronized with current issues.
    ///
    /// When `true`, instead of processing issues normally, compares the baseline
    /// against current issues and reports discrepancies. Exits with failure if
    /// the baseline is out of sync. Requires `baseline_path` to be set.
    pub verify_baseline: bool,

    /// Remove outdated entries from the baseline file without adding new ones.
    ///
    /// When `true`, instead of processing issues normally, the baseline file is
    /// rewritten with stale entries removed. Entries for issues that no longer
    /// exist are dropped, while issues introduced since the baseline was created
    /// are left unsuppressed. Requires `baseline_path` to be set.
    pub remove_outdated_baseline_entries: bool,

    /// Fail even when only the baseline is out of sync.
    ///
    /// Normally, if there are no new issues to report, the command succeeds even
    /// if the baseline contains issues that no longer exist. When `true`, the
    /// command fails whenever the baseline is out of sync, ensuring baselines
    /// stay clean and up-to-date.
    pub fail_on_out_of_sync_baseline: bool,

    /// The baseline variant to use when generating new baselines.
    ///
    /// This determines the format of generated baseline files:
    /// - `Strict`: Exact line matching with start/end line numbers
    /// - `Loose`: Count-based matching by (file, code, message) tuple
    ///
    /// When loading existing baselines, the variant is determined by the file's
    /// `variant` header, not this setting.
    pub baseline_variant: BaselineVariant,

    /// Wrapped issue processor for actual issue processing.
    ///
    /// After baseline operations (loading, generation, or verification), this
    /// processor handles the actual reporting or fixing of issues.
    pub issue_processor: IssueProcessor,
}

impl IssueProcessor {
    /// Processes issues by either reporting them or applying fixes.
    ///
    /// This is the main entry point for issue processing. Depending on whether the `--fix`
    /// flag is enabled, it either applies automatic fixes to the code or reports the issues
    /// using the configured format and output settings.
    ///
    /// When applying fixes, only safe fixes are applied by default unless `--unsafe` or
    /// `--potentially-unsafe` flags are provided. When reporting, issues can be filtered,
    /// sorted, and formatted according to the configured options.
    ///
    /// Returns an exit code indicating success or failure based on whether issues were
    /// found and whether they meet the configured failure threshold.
    pub fn process_issues(
        &self,
        orchestrator: &Orchestrator<'_>,
        database: &mut Database<'_>,
        mut issues: IssueCollection,
        baseline: Option<Baseline>,
        fail_on_out_of_sync_baseline: bool,
    ) -> Result<(ExitCode, Vec<FileId>), Error> {
        if !self.retain_code.is_empty() {
            let total_before_filter = issues.len();
            issues.filter_retain_codes(&self.retain_code);
            let total_after_filter = issues.len();
            let filtered_count = total_before_filter - total_after_filter;

            let codes_list = self.retain_code.join(", ");

            if total_after_filter == 0 && total_before_filter > 0 {
                tracing::warn!("No issues found matching code(s): {}", codes_list);
            } else if filtered_count > 0 {
                tracing::info!(
                    "Retaining {} of {} issues with code(s): {}",
                    total_after_filter,
                    total_before_filter,
                    codes_list
                );
            }
        }

        if self.fix {
            self.handle_fix_mode(orchestrator, database, issues, baseline)
        } else {
            self.handle_report_mode(database, issues, baseline, fail_on_out_of_sync_baseline)
                .map(|code| (code, Vec::new()))
        }
    }

    /// Applies automatic fixes to code when the `--fix` flag is enabled.
    ///
    /// This method filters fixes based on safety classification and applies them
    /// in parallel. It respects the `--unsafe` and `--potentially-unsafe` flags
    /// to determine which fixes are safe to apply. When `--format-after-fix` is
    /// enabled, modified files are automatically formatted. When `--dry-run` is
    /// enabled, changes are previewed but not written to disk.
    fn handle_fix_mode(
        &self,
        orchestrator: &Orchestrator<'_>,
        database: &mut Database<'_>,
        issues: IssueCollection,
        baseline: Option<Baseline>,
    ) -> Result<(ExitCode, Vec<FileId>), Error> {
        let issues =
            if let Some(baseline) = baseline { baseline.filter_issues(issues, &database.read_only()) } else { issues };

        let unfixable_count = (&issues).into_iter().filter(|i| i.edits.is_empty()).count();

        let dry_run = self.dry_run;
        let (applied_fixes, skipped_unsafe, skipped_potentially_unsafe, bugs, changed_file_ids) =
            self.apply_fixes(orchestrator, database, issues)?;

        if skipped_unsafe > 0 {
            tracing::warn!("Skipped {skipped_unsafe} unsafe fixes. Use `--unsafe` to apply them.");
        }

        if skipped_potentially_unsafe > 0 {
            tracing::warn!(
                "Skipped {skipped_potentially_unsafe} potentially unsafe fixes. Use `--potentially-unsafe` or `--unsafe` to apply them.",
            );
        }

        let success = {
            if applied_fixes == 0 {
                tracing::info!("No fixes were applied.");

                true
            } else if dry_run {
                tracing::info!("Found {applied_fixes} fixes that can be applied (dry-run).");

                false
            } else {
                tracing::info!("Successfully applied {applied_fixes} fixes.");

                true
            }
        };

        if bugs > 0 {
            tracing::error!("Encountered {bugs} bugs while applying fixes. Please report them at {}", ISSUE_URL);

            return Ok((ExitCode::FAILURE, changed_file_ids));
        }

        if success && self.fail_on_remaining {
            let skipped_safety = skipped_unsafe + skipped_potentially_unsafe;
            let remaining = unfixable_count + skipped_safety;
            if remaining > 0 {
                if unfixable_count > 0 {
                    tracing::warn!(
                        "{unfixable_count} issues require manual attention and cannot be fixed automatically."
                    );
                }

                if skipped_safety > 0 {
                    tracing::warn!(
                        "{skipped_safety} issues were skipped due to safety level. Use `--potentially-unsafe` or `--unsafe` to apply them.",
                    );
                }

                return Ok((ExitCode::FAILURE, changed_file_ids));
            }
        }

        Ok((if success { ExitCode::SUCCESS } else { ExitCode::FAILURE }, changed_file_ids))
    }

    /// Reports issues to the configured output target when `--fix` is not enabled.
    ///
    /// This method creates a reporter with the configured settings and outputs
    /// issues according to the specified format. It applies baseline filtering
    /// if a baseline is provided, filters by severity level if configured, and
    /// can optionally filter to show only fixable issues or sort issues for
    /// better readability.
    ///
    /// The exit code is determined by the highest severity level of reported
    /// issues compared to the `--minimum-fail-level` threshold.
    fn handle_report_mode<'d>(
        &self,
        database: &'d Database<'d>,
        issues: IssueCollection,
        baseline: Option<Baseline>,
        fail_on_out_of_sync_baseline: bool,
    ) -> Result<ExitCode, Error> {
        let read_database = database.read_only();

        let issues_to_report = issues;

        let reporter_configuration = ReporterConfig {
            target: self.reporting_target.clone(),
            format: self.reporting_format,
            color_choice: match self.color_choice {
                ColorChoice::Auto => ReportingColorChoice::Auto,
                ColorChoice::Always => ReportingColorChoice::Always,
                ColorChoice::Never => ReportingColorChoice::Never,
            },
            filter_fixable: self.fixable_only,
            sort: self.sort,
            minimum_report_level: self.minimum_report_level,
            editor_url: self.editor_url.clone(),
        };

        let reporter = Reporter::new(read_database, reporter_configuration);
        let status = reporter.report(issues_to_report, baseline)?;

        if status.baseline_dead_issues > 0 {
            let dead = status.baseline_dead_issues;
            let noun = if dead == 1 { "entry" } else { "entries" };

            if self.scoped_analysis {
                tracing::info!(
                    "Your baseline file contains {dead} {noun} not seen in this run. This was a scoped run, so they may simply be outside the analyzed set. Re-run an unscoped check before regenerating with `--generate-baseline`."
                );
            } else {
                tracing::warn!(
                    "Your baseline file contains {dead} {noun} for issues that no longer exist. Consider regenerating it with `--generate-baseline`."
                );

                if fail_on_out_of_sync_baseline {
                    return Ok(ExitCode::FAILURE);
                }
            }
        }

        if status.baseline_filtered_issues > 0 {
            tracing::info!("Filtered out {} issues based on the baseline file.", status.baseline_filtered_issues);
        }

        if let Some(highest_reported_level) = status.highest_reported_level
            && self.minimum_fail_level <= highest_reported_level
        {
            return Ok(ExitCode::FAILURE);
        }

        if status.total_reported_issues == 0 {
            if self.fixable_only {
                tracing::info!("No fixable issues found.");
            } else {
                tracing::info!("No issues found.");
            }
        }

        Ok(ExitCode::SUCCESS)
    }

    /// Applies code fixes in parallel according to safety settings.
    ///
    /// This method extracts edits from issues, groups them by file, and applies
    /// them concurrently using a parallel thread pool. The TextEditor handles
    /// safety filtering based on the configured threshold. Each fix can optionally
    /// be followed by code formatting if `--format-after-fix` is enabled.
    ///
    /// Returns a tuple containing:
    ///
    /// - Number of files successfully modified
    /// - Number of unsafe fixes skipped
    /// - Number of potentially unsafe fixes skipped
    /// - Number of bugs encountered during fix application, such as out-of-bounds edits or
    ///   rejected edits
    fn apply_fixes(
        &self,
        orchestrator: &Orchestrator<'_>,
        database: &mut Database<'_>,
        issues: IssueCollection,
    ) -> Result<(usize, usize, usize, usize, Vec<FileId>), Error> {
        let read_database = Arc::new(database.read_only());
        let change_log = ChangeLog::new();

        // Determine safety threshold based on flags
        let safety_threshold = if self.r#unsafe {
            Safety::Unsafe
        } else if self.potentially_unsafe {
            Safety::PotentiallyUnsafe
        } else {
            Safety::Safe
        };

        // Collect edit batches by file (each batch = all edits from one issue)
        let batches_by_file = issues.to_edit_batches();
        if batches_by_file.is_empty() {
            return Ok((0, 0, 0, 0, Vec::new()));
        }

        let format_after_fix = self.format_after_fix;
        let dry_run = self.dry_run;
        let color_choice = self.color_choice;
        let parser_settings = orchestrator.config.parser_settings;

        let results: Vec<(bool, usize, usize, usize)> = batches_by_file
            .into_par_iter()
            .map_init(LocalArena::new, |arena, (file_id, batches)| {
                let file = read_database.get_ref(&file_id)?;
                let mut editor = TextEditor::with_safety(&file.contents, safety_threshold);

                let mut skipped_unsafe = 0usize;
                let mut skipped_potentially_unsafe = 0usize;
                let mut bugs = 0usize;

                // Each batch contains all edits from a single issue - they must be applied together
                for (rule_code, edits) in batches {
                    let rule_code = rule_code.as_deref().unwrap_or("unknown");
                    let result = editor.apply_batch(edits, Some(|code: &[u8]| check_php_code(arena, file_id, code, parser_settings)));
                    arena.reset();

                    match result {
                        ApplyResult::Applied => {
                            // Successfully applied
                        }
                        ApplyResult::Unsafe => {
                            skipped_unsafe += 1;
                        }
                        ApplyResult::PotentiallyUnsafe => {
                            skipped_potentially_unsafe += 1;
                        }
                        ApplyResult::OutOfBounds => {
                            tracing::warn!("Edit out of bounds for `{}` (issue: `{rule_code}`). This is a bug in Mago.", mago_bytes::BytesDisplay(&file.name));
                            tracing::error!("Please report this issue at {}", ISSUE_URL);

                            bugs += 1;
                        }
                        ApplyResult::Overlap => {
                            tracing::warn!("Overlapping edit for `{}` (issue: `{rule_code}`), skipping.", mago_bytes::BytesDisplay(&file.name));
                            tracing::warn!("This can happen when multiple fixers modify the same code. Try running the fixer again to apply remaining fixes.");
                        }
                        ApplyResult::Rejected => {
                            tracing::error!("Edit for `{}` (issue: `{rule_code}`) was rejected because it would produce invalid PHP syntax.", mago_bytes::BytesDisplay(&file.name));
                            tracing::error!("This is a bug in Mago. Please report this issue at {}", ISSUE_URL);

                            bugs += 1;
                        }
                        _ => {
                            tracing::error!("Unexpected edit application result for `{}` (issue: `{rule_code}`). This is a bug in Mago.", mago_bytes::BytesDisplay(&file.name));
                            tracing::error!("Please report this issue at {}", ISSUE_URL);

                            bugs += 1;
                        }
                    }
                }

                let fixed_content = editor.finish();
                if *fixed_content == *file.contents {
                    return Ok((false, skipped_unsafe, skipped_potentially_unsafe, bugs));
                }

                let final_content = if format_after_fix {
                    let ephemeral_file = File::ephemeral(file.name.clone(), Cow::Owned(fixed_content));
                    let format_status = orchestrator.format_file_in(&ephemeral_file, arena)?;

                    match format_status {
                        FileFormatStatus::Unchanged => ephemeral_file.contents.into_owned(),
                        FileFormatStatus::Changed(new_content) => new_content,
                        FileFormatStatus::FailedToParse(parse_error) => {
                            tracing::warn!(
                                "Failed to format file `{}` after applying fixes: {}",
                                mago_bytes::BytesDisplay(&ephemeral_file.name),
                                parse_error
                            );

                            ephemeral_file.contents.into_owned()
                        }
                    }
                } else {
                    fixed_content
                };

                arena.reset();

                let changed = utils::apply_update(&change_log, file, final_content.as_ref(), dry_run, color_choice)?;
                Ok((changed, skipped_unsafe, skipped_potentially_unsafe, bugs))
            })
            .collect::<Result<Vec<(bool, usize, usize, usize)>, Error>>()?;

        let changed_file_ids = if !dry_run {
            let ids = change_log.changed_file_ids()?;
            database.commit(change_log, true)?;
            ids
        } else {
            Vec::new()
        };

        let mut applied_fix_count = 0;
        let mut total_skipped_unsafe = 0;
        let mut total_skipped_potentially_unsafe = 0;
        let mut total_bugs = 0;

        for (changed, skipped_unsafe, skipped_potentially_unsafe, bugs) in results {
            if changed {
                applied_fix_count += 1;
            }
            total_skipped_unsafe += skipped_unsafe;
            total_skipped_potentially_unsafe += skipped_potentially_unsafe;
            total_bugs += bugs;
        }

        Ok((applied_fix_count, total_skipped_unsafe, total_skipped_potentially_unsafe, total_bugs, changed_file_ids))
    }
}

impl BaselineIssueProcessor {
    /// Processes issues with baseline awareness.
    ///
    /// This method orchestrates the complete baseline-aware issue processing workflow.
    /// Depending on the configured flags, it either generates a baseline, verifies a
    /// baseline, or processes issues normally with optional baseline filtering.
    ///
    /// # Workflow
    ///
    /// The method follows this decision tree:
    ///
    /// 1. **Baseline Path Provided**:
    ///    - If `generate_baseline` is `true`: Generate new baseline and exit with success
    ///    - If `verify_baseline` is `true`: Verify baseline and exit with success/failure
    ///    - Otherwise: Load baseline and pass to wrapped processor for filtering
    /// 2. **No Baseline Path**:
    ///    - Validate that baseline-related flags are consistent
    ///    - Process issues normally without baseline filtering
    ///
    /// # Arguments
    ///
    /// * `orchestrator` - The orchestrator for formatting fixed files
    /// * `database` - The database containing source files
    /// * `issues` - The collection of issues to process
    ///
    /// # Returns
    ///
    /// - `Ok(ExitCode::SUCCESS)` - Operation completed successfully
    /// - `Ok(ExitCode::FAILURE)` - Issues found above fail threshold, baseline out of sync,
    ///   or validation failed
    /// - `Err(Error)` - Baseline I/O error or issue processing error
    pub fn process_issues(
        &self,
        orchestrator: &Orchestrator<'_>,
        database: &mut Database<'_>,
        issues: IssueCollection,
    ) -> Result<(ExitCode, Vec<FileId>), Error> {
        // Extract baseline_path before consuming self
        let baseline = if let Some(baseline_path_cow) = self.baseline_path.as_ref() {
            let baseline_path = baseline_path_cow.as_ref();
            if self.generate_baseline {
                let read_database = database.read_only();
                self.generate_baseline(baseline_path, &read_database, issues)?;

                return Ok((ExitCode::SUCCESS, Vec::new()));
            }

            if self.verify_baseline {
                let read_database = database.read_only();
                let success = self.verify_baseline(baseline_path, &read_database, issues)?;

                return Ok((if success { ExitCode::SUCCESS } else { ExitCode::FAILURE }, Vec::new()));
            }

            if self.remove_outdated_baseline_entries {
                let read_database = database.read_only();
                self.remove_outdated_baseline_entries(baseline_path, &read_database, issues)?;

                return Ok((ExitCode::SUCCESS, Vec::new()));
            }

            self.get_baseline(Some(baseline_path))
        } else {
            if !self.validate_baseline_parameters() {
                return Ok((ExitCode::FAILURE, Vec::new()));
            }

            None
        };

        self.issue_processor.process_issues(orchestrator, database, issues, baseline, self.fail_on_out_of_sync_baseline)
    }

    /// Loads an existing baseline file from disk.
    ///
    /// This method attempts to read and deserialize a baseline file from the specified
    /// path. If the file doesn't exist or cannot be read, appropriate warnings or
    /// errors are logged and `None` is returned.
    ///
    /// # Arguments
    ///
    /// * `baseline_path` - Optional path to the baseline file
    ///
    /// # Returns
    ///
    /// - `Some(Baseline)` if the file exists and was successfully deserialized
    /// - `None` if no path was provided, the file doesn't exist, or deserialization failed
    fn get_baseline(&self, baseline_path: Option<&Path>) -> Option<Baseline> {
        let path = baseline_path?;
        if !path.exists() {
            tracing::warn!("Baseline file `{}` does not exist.", path.display());

            return None;
        }

        match unserialize_baseline(path) {
            Ok((baseline, needs_warning)) => {
                if needs_warning {
                    tracing::warn!(
                        "Baseline file does not specify a variant, assuming 'strict'. \
                         Regenerate the baseline with `--generate-baseline` to update the format."
                    );
                }
                Some(baseline)
            }
            Err(err) => {
                tracing::error!("Failed to read baseline file at `{}`: {}", path.display(), err);

                None
            }
        }
    }

    /// Generates a new baseline file from the provided issues.
    ///
    /// This method creates a baseline containing all issues in the provided collection,
    /// writes it to the specified path, and optionally creates a backup of any existing
    /// baseline file.
    ///
    /// # Arguments
    ///
    /// * `baseline_path` - Path where the baseline file should be written
    /// * `read_database` - Read-only database access for file metadata
    /// * `issues` - Collection of issues to include in the baseline
    ///
    /// # Returns
    ///
    /// - `Ok(())` if the baseline was successfully generated and written
    /// - `Err(Error)` if file I/O operations failed
    ///
    /// # Side Effects
    ///
    /// - Creates or overwrites the baseline file at `baseline_path`
    /// - If `backup_baseline` is `true` and a baseline already exists, creates a backup
    ///   with a `.bkp` extension
    /// - Logs informational messages about the generation process
    fn generate_baseline(
        &self,
        baseline_path: &Path,
        read_database: &ReadDatabase,
        issues: IssueCollection,
    ) -> Result<(), Error> {
        tracing::info!("Generating {:?} baseline file...", self.baseline_variant);
        let baseline = Baseline::generate_from_issues(&issues, read_database, self.baseline_variant);
        baseline::serialize_baseline(baseline_path, &baseline, self.backup_baseline)?;
        tracing::info!("Baseline file successfully generated at `{}`.", baseline_path.display());

        Ok(())
    }

    /// Verifies that an existing baseline is synchronized with current issues.
    ///
    /// This method loads the baseline file, compares it against the current set of issues,
    /// and reports any discrepancies. Discrepancies include new issues not in the baseline
    /// and baseline entries for issues that no longer exist.
    ///
    /// # Arguments
    ///
    /// * `baseline_path` - Path to the baseline file to verify
    /// * `read_database` - Read-only database access for file metadata
    /// * `issues` - Current collection of issues to compare against the baseline
    ///
    /// # Returns
    ///
    /// - `Ok(true)` if the baseline is up-to-date with no discrepancies
    /// - `Ok(false)` if the baseline is out of sync (new issues, removed issues, or file changes)
    /// - `Err(Error)` if the baseline file cannot be read or deserialized
    ///
    /// # Side Effects
    ///
    /// Logs informational messages, warnings, and errors about the verification results:
    /// - Info: Whether verification is starting or if baseline is up-to-date
    /// - Warning: Counts of new issues and removed issues
    /// - Error: Overall summary of files with changes and suggestion to regenerate
    fn verify_baseline(
        &self,
        baseline_path: &Path,
        read_database: &ReadDatabase,
        issues: IssueCollection,
    ) -> Result<bool, Error> {
        if !baseline_path.exists() {
            tracing::info!("Baseline file `{}` does not exist.", baseline_path.display());
            return Ok(false);
        }

        tracing::info!("Verifying baseline file at `{}`...", baseline_path.display());

        let (baseline, needs_warning) = unserialize_baseline(baseline_path)?;
        if needs_warning {
            tracing::warn!(
                "Baseline file does not specify a variant, assuming 'strict'. \
                 Regenerate the baseline with `--generate-baseline` to update the format."
            );
        }
        let comparison = baseline.compare_with_issues(&issues, read_database);
        let scoped_with_no_new_issues = self.issue_processor.scoped_analysis && comparison.new_issues.is_empty();

        if comparison.is_up_to_date {
            tracing::info!("Baseline is up to date.");

            if self.issue_processor.reporting_format == ReportingFormat::Json {
                let output = VerifyBaselineOutput { new_issues: &[], removed_issues: &[] };
                println!(
                    "{}",
                    serde_json::to_string(&output).expect("failed to serialize baseline verification output")
                );
            }

            Ok(true)
        } else if scoped_with_no_new_issues {
            if !comparison.removed_issues.is_empty() {
                let removed = comparison.removed_issues.len();
                let noun = if removed == 1 { "entry" } else { "entries" };
                tracing::info!(
                    "{removed} baseline {noun} were not seen in this scoped run; they may simply be outside the analyzed set. Re-run an unscoped check before regenerating the baseline."
                );
            }

            if self.issue_processor.reporting_format == ReportingFormat::Json {
                let output = VerifyBaselineOutput { new_issues: &[], removed_issues: &comparison.removed_issues };
                println!(
                    "{}",
                    serde_json::to_string(&output).expect("failed to serialize baseline verification output")
                );
            }

            Ok(true)
        } else {
            if !comparison.new_issues.is_empty() {
                tracing::warn!("Found {} new issues not in the baseline.", comparison.new_issues.len());
            }

            if !comparison.removed_issues.is_empty() {
                tracing::warn!(
                    "Found {} issues in the baseline that no longer exist.",
                    comparison.removed_issues.len()
                );
            }

            tracing::error!("Baseline is outdated. {} files have changes.", comparison.files_with_changes_count);
            tracing::error!("Run with `--generate-baseline` to update the baseline file.");

            if self.issue_processor.reporting_format == ReportingFormat::Json {
                let output = VerifyBaselineOutput {
                    new_issues: &comparison.new_issues,
                    removed_issues: &comparison.removed_issues,
                };
                println!(
                    "{}",
                    serde_json::to_string(&output).expect("failed to serialize baseline verification output")
                );
            }

            Ok(false)
        }
    }

    /// Removes outdated entries from an existing baseline file.
    fn remove_outdated_baseline_entries(
        &self,
        baseline_path: &Path,
        read_database: &ReadDatabase,
        issues: IssueCollection,
    ) -> Result<(), Error> {
        if !baseline_path.exists() {
            tracing::warn!(
                "Baseline file `{}` does not exist; there are no outdated entries to remove.",
                baseline_path.display()
            );

            return Ok(());
        }

        tracing::info!("Removing outdated entries from baseline file at `{}`...", baseline_path.display());

        let (baseline, needs_warning) = unserialize_baseline(baseline_path)?;
        if needs_warning {
            tracing::warn!(
                "Baseline file does not specify a variant, assuming 'strict'. \
                 Regenerate the baseline with `--generate-baseline` to update the format."
            );
        }

        let (pruned_baseline, removed_count) = baseline.prune_outdated_entries(&issues, read_database);

        if removed_count == 0 {
            tracing::info!("No outdated entries found; the baseline file is already up to date.");

            return Ok(());
        }

        baseline::serialize_baseline(baseline_path, &pruned_baseline, self.backup_baseline)?;

        let noun = if removed_count == 1 { "entry" } else { "entries" };
        tracing::info!(
            "Removed {removed_count} outdated {noun} from the baseline file at `{}`.",
            baseline_path.display()
        );

        Ok(())
    }

    /// Validates that baseline-related flags are consistent with the absence of a baseline path.
    ///
    /// This method checks whether baseline operations were requested (`generate_baseline`,
    /// `verify_baseline`, or `fail_on_out_of_sync_baseline`) without providing a baseline
    /// path. It logs appropriate warnings when invalid combinations are detected.
    ///
    /// # Returns
    ///
    /// - `true` if the configuration is valid (no baseline operations requested, or only
    ///   `fail_on_out_of_sync_baseline` is set)
    /// - `false` if `generate_baseline` or `verify_baseline` is set without a baseline path
    ///
    /// # Side Effects
    ///
    /// Logs warnings when invalid configurations are detected, guiding the user to either:
    /// - Use the `--baseline <PATH>` CLI option
    /// - Set a default baseline path in the configuration file
    fn validate_baseline_parameters(&self) -> bool {
        if self.generate_baseline {
            tracing::warn!("Cannot generate baseline file because no baseline path was specified.");
            tracing::warn!("Use the `--baseline <PATH>` option to specify where to save the baseline file.");
            tracing::warn!("Or set a default baseline path in the configuration file.");

            false
        } else if self.verify_baseline {
            tracing::warn!("Cannot verify baseline file because no baseline path was specified.");
            tracing::warn!("Use the `--baseline <PATH>` option to specify the baseline file to verify.");
            tracing::warn!("Or set a default baseline path in the configuration file.");

            false
        } else if self.remove_outdated_baseline_entries {
            tracing::warn!("Cannot remove outdated baseline entries because no baseline path was specified.");
            tracing::warn!("Use the `--baseline <PATH>` option to specify the baseline file to update.");
            tracing::warn!("Or set a default baseline path in the configuration file.");

            false
        } else if self.fail_on_out_of_sync_baseline {
            tracing::warn!("Cannot fail on out-of-sync baseline because no baseline path was specified.");
            tracing::warn!("Use the `--baseline <PATH>` option to specify the baseline file.");
            tracing::warn!("Or set a default baseline path in the configuration file.");
            true
        } else {
            true
        }
    }
}

/// Checks if the given PHP code snippet is syntactically valid.
///
/// # Arguments
///
/// * `arena` - The memory arena to use for parsing.
/// * `file_id` - The identifier for the temporary file.
/// * `code` - The PHP code snippet to check.
/// * `parser_settings` - The parser settings to use for parsing.
///
/// # Returns
///
/// * `true` if the code is syntactically valid, `false` otherwise.
fn check_php_code(arena: &LocalArena, file_id: FileId, code: &[u8], parser_settings: ParserSettings) -> bool {
    !parse_file_content_with_settings(arena, file_id, code, parser_settings).has_errors()
}