Skip to main content

mdx_rust_core/
refactor.rs

1//! Plan-first guardrailed refactoring.
2//!
3//! v0.9 keeps auditable plans as the mutation boundary and adds file/function
4//! evidence plus security posture to the safe executable subset.
5
6use crate::eval::stable_hash_hex;
7use crate::evidence::{load_latest_evidence_for_root, EvidenceArtifactRef, EvidenceRun};
8use crate::hardening::{
9    run_hardening, workspace_summary, HardeningConfig, HardeningRun, WorkspaceSummary,
10};
11use crate::policy::{load_project_policy, ProjectPolicy};
12use crate::security::{audit_agent, AuditFinding, AuditSeverity};
13use mdx_rust_analysis::{
14    analyze_hardening, analyze_refactor, HardeningAnalyzeConfig, HardeningEvidenceDepth,
15    HardeningFinding, ModuleEdge, RefactorAnalyzeConfig, RefactorFileSummary,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use std::path::{Component, Path, PathBuf};
20use std::time::Duration;
21
22#[derive(Debug, Clone)]
23pub struct RefactorPlanConfig {
24    pub target: Option<PathBuf>,
25    pub policy_path: Option<PathBuf>,
26    pub behavior_spec_path: Option<PathBuf>,
27    pub max_files: usize,
28}
29
30impl Default for RefactorPlanConfig {
31    fn default() -> Self {
32        Self {
33            target: None,
34            policy_path: None,
35            behavior_spec_path: None,
36            max_files: 100,
37        }
38    }
39}
40
41#[derive(Debug, Clone)]
42pub struct RefactorApplyConfig {
43    pub plan_path: PathBuf,
44    pub candidate_id: String,
45    pub apply: bool,
46    pub allow_public_api_impact: bool,
47    pub validation_timeout: Duration,
48}
49
50#[derive(Debug, Clone)]
51pub struct RefactorBatchApplyConfig {
52    pub plan_path: PathBuf,
53    pub apply: bool,
54    pub allow_public_api_impact: bool,
55    pub validation_timeout: Duration,
56    pub max_candidates: usize,
57    pub max_tier: RecipeTier,
58    pub min_evidence: EvidenceGrade,
59}
60
61#[derive(Debug, Clone)]
62pub struct CodebaseMapConfig {
63    pub target: Option<PathBuf>,
64    pub policy_path: Option<PathBuf>,
65    pub behavior_spec_path: Option<PathBuf>,
66    pub max_files: usize,
67}
68
69impl Default for CodebaseMapConfig {
70    fn default() -> Self {
71        Self {
72            target: None,
73            policy_path: None,
74            behavior_spec_path: None,
75            max_files: 250,
76        }
77    }
78}
79
80#[derive(Debug, Clone)]
81pub struct AutopilotConfig {
82    pub target: Option<PathBuf>,
83    pub policy_path: Option<PathBuf>,
84    pub behavior_spec_path: Option<PathBuf>,
85    pub apply: bool,
86    pub max_files: usize,
87    pub max_passes: usize,
88    pub max_candidates: usize,
89    pub validation_timeout: Duration,
90    pub allow_public_api_impact: bool,
91    pub max_tier: RecipeTier,
92    pub min_evidence: EvidenceGrade,
93    pub budget: Option<Duration>,
94}
95
96impl Default for AutopilotConfig {
97    fn default() -> Self {
98        Self {
99            target: None,
100            policy_path: None,
101            behavior_spec_path: None,
102            apply: false,
103            max_files: 250,
104            max_passes: 3,
105            max_candidates: 25,
106            validation_timeout: Duration::from_secs(180),
107            allow_public_api_impact: false,
108            max_tier: RecipeTier::Tier1,
109            min_evidence: EvidenceGrade::Compiled,
110            budget: None,
111        }
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
116pub struct RefactorPlan {
117    pub schema_version: String,
118    pub plan_id: String,
119    pub plan_hash: String,
120    pub root: String,
121    pub target: Option<String>,
122    pub workspace: WorkspaceSummary,
123    pub policy: Option<ProjectPolicy>,
124    pub behavior_spec: Option<String>,
125    pub evidence: EvidenceSummary,
126    pub measured_evidence: Option<EvidenceArtifactRef>,
127    #[serde(default)]
128    pub security: SecurityPostureSummary,
129    #[serde(default)]
130    pub autonomy: AutonomyReadiness,
131    pub impact: RefactorImpactSummary,
132    pub source_snapshots: Vec<SourceSnapshot>,
133    pub files: Vec<RefactorFileSummary>,
134    pub module_edges: Vec<ModuleEdge>,
135    pub candidates: Vec<RefactorCandidate>,
136    pub required_gates: Vec<String>,
137    pub non_goals: Vec<String>,
138    pub artifact_path: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142pub struct CodebaseMap {
143    pub schema_version: String,
144    pub map_id: String,
145    pub map_hash: String,
146    pub root: String,
147    pub target: Option<String>,
148    pub workspace: WorkspaceSummary,
149    pub policy: Option<ProjectPolicy>,
150    pub behavior_spec: Option<String>,
151    pub evidence: EvidenceSummary,
152    pub measured_evidence: Option<EvidenceArtifactRef>,
153    #[serde(default)]
154    pub security: SecurityPostureSummary,
155    #[serde(default)]
156    pub autonomy: AutonomyReadiness,
157    pub quality: CodebaseQualitySummary,
158    pub capability_gates: Vec<CapabilityGate>,
159    pub impact: RefactorImpactSummary,
160    pub files: Vec<RefactorFileSummary>,
161    pub module_edges: Vec<ModuleEdge>,
162    pub findings: Vec<HardeningFinding>,
163    pub recommended_actions: Vec<String>,
164    pub artifact_path: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
168pub struct CodebaseQualitySummary {
169    pub grade: CodebaseQualityGrade,
170    pub debt_score: u8,
171    #[serde(default)]
172    pub security_score: u8,
173    pub patchable_findings: usize,
174    pub review_only_findings: usize,
175    pub public_api_pressure: usize,
176    pub oversized_files: usize,
177    pub oversized_functions: usize,
178    pub test_coverage_signal: TestCoverageSignal,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
182pub struct EvidenceSummary {
183    pub grade: EvidenceGrade,
184    pub max_autonomous_tier: u8,
185    pub analysis_depth: EvidenceAnalysisDepth,
186    pub signals: Vec<EvidenceSignal>,
187    #[serde(default)]
188    pub profiled_files: usize,
189    pub unlocked_recipe_tiers: Vec<String>,
190    pub unlock_suggestions: Vec<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
194pub enum EvidenceAnalysisDepth {
195    None,
196    Mechanical,
197    BoundaryAware,
198    Structural,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
202pub struct EvidenceSignal {
203    pub id: String,
204    pub label: String,
205    pub present: bool,
206    pub detail: String,
207}
208
209#[derive(
210    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
211)]
212pub enum EvidenceGrade {
213    None,
214    Compiled,
215    Tested,
216    Covered,
217    Hardened,
218    Proven,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
222pub enum CodebaseQualityGrade {
223    Excellent,
224    Good,
225    NeedsWork,
226    HighRisk,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
230pub enum TestCoverageSignal {
231    Present,
232    Sparse,
233    Unknown,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
237pub struct CapabilityGate {
238    pub id: String,
239    pub label: String,
240    pub available: bool,
241    pub command: String,
242    pub purpose: String,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
246pub struct CandidateEvidenceContext {
247    pub grade: EvidenceGrade,
248    #[serde(default)]
249    pub status: CandidateEvidenceStatus,
250    pub source: String,
251    pub profiled_file: Option<String>,
252    pub signals: Vec<String>,
253}
254
255impl Default for CandidateEvidenceContext {
256    fn default() -> Self {
257        Self {
258            grade: EvidenceGrade::None,
259            status: CandidateEvidenceStatus::Unmeasured,
260            source: "legacy artifact without candidate evidence context".to_string(),
261            profiled_file: None,
262            signals: Vec::new(),
263        }
264    }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
268pub enum CandidateEvidenceStatus {
269    #[default]
270    Unmeasured,
271    Compiled,
272    Tested,
273    Covered,
274    MutationBacked,
275    Proven,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
279pub struct SecurityPostureSummary {
280    pub score: u8,
281    pub high: usize,
282    pub medium: usize,
283    pub low: usize,
284    pub info: usize,
285    pub top_findings: Vec<String>,
286}
287
288impl Default for SecurityPostureSummary {
289    fn default() -> Self {
290        Self {
291            score: 100,
292            high: 0,
293            medium: 0,
294            low: 0,
295            info: 0,
296            top_findings: Vec::new(),
297        }
298    }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
302pub struct AutonomyReadiness {
303    pub grade: AutonomyReadinessGrade,
304    pub max_safe_tier: RecipeTier,
305    pub executable_candidates: usize,
306    pub review_only_candidates: usize,
307    pub blocked_candidates: usize,
308    pub blockers: Vec<String>,
309    pub recommended_command: Option<String>,
310}
311
312impl Default for AutonomyReadiness {
313    fn default() -> Self {
314        Self {
315            grade: AutonomyReadinessGrade::Blocked,
316            max_safe_tier: RecipeTier::Tier1,
317            executable_candidates: 0,
318            review_only_candidates: 0,
319            blocked_candidates: 0,
320            blockers: Vec::new(),
321            recommended_command: None,
322        }
323    }
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
327pub enum AutonomyReadinessGrade {
328    Blocked,
329    ReviewOnly,
330    Tier1Ready,
331    Tier2Ready,
332    Tier3Planning,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
336pub struct CandidateAutonomyDecision {
337    pub decision: AutonomyDecision,
338    pub reasons: Vec<String>,
339}
340
341impl Default for CandidateAutonomyDecision {
342    fn default() -> Self {
343        Self {
344            decision: AutonomyDecision::ReviewOnly,
345            reasons: vec!["legacy artifact without explicit autonomy decision".to_string()],
346        }
347    }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
351pub enum AutonomyDecision {
352    Allowed,
353    Blocked,
354    ReviewOnly,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
358pub struct AutopilotRun {
359    pub schema_version: String,
360    pub run_id: String,
361    pub root: String,
362    pub target: Option<String>,
363    pub mode: RefactorApplyMode,
364    pub status: AutopilotStatus,
365    pub budget_seconds: Option<u64>,
366    pub max_passes: usize,
367    pub max_candidates_per_pass: usize,
368    pub quality_before: CodebaseQualitySummary,
369    pub quality_after: Option<CodebaseQualitySummary>,
370    pub evidence: EvidenceSummary,
371    pub measured_evidence: Option<EvidenceArtifactRef>,
372    pub execution_summary: AutopilotExecutionSummary,
373    pub passes: Vec<AutopilotPass>,
374    pub total_planned_candidates: usize,
375    pub total_executed_candidates: usize,
376    pub total_skipped_candidates: usize,
377    pub budget_exhausted: bool,
378    pub note: String,
379    pub artifact_path: Option<String>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
383pub struct AutopilotExecutionSummary {
384    pub plans_created: usize,
385    pub executable_candidates_seen: usize,
386    pub validated_transactions: usize,
387    pub applied_transactions: usize,
388    pub blocked_or_plan_only_candidates: usize,
389    pub evidence_grade: EvidenceGrade,
390    pub analysis_depth: EvidenceAnalysisDepth,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
394pub struct AutopilotPass {
395    pub pass_index: usize,
396    pub plan_id: String,
397    pub plan_hash: String,
398    pub plan_artifact_path: Option<String>,
399    pub planned_candidates: usize,
400    pub executable_candidates: usize,
401    pub batch: Option<RefactorBatchApplyRun>,
402    pub status: AutopilotPassStatus,
403    pub note: String,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
407pub enum AutopilotStatus {
408    Reviewed,
409    Applied,
410    PartiallyApplied,
411    NoExecutableCandidates,
412    Rejected,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
416pub enum AutopilotPassStatus {
417    Planned,
418    Reviewed,
419    Applied,
420    PartiallyApplied,
421    NoExecutableCandidates,
422    Rejected,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
426pub struct SourceSnapshot {
427    pub file: String,
428    pub hash: String,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
432pub struct RefactorImpactSummary {
433    pub files_scanned: usize,
434    pub public_item_count: usize,
435    pub public_files: usize,
436    pub module_edge_count: usize,
437    pub patchable_hardening_changes: usize,
438    pub review_only_findings: usize,
439    pub oversized_files: usize,
440    pub oversized_functions: usize,
441    pub risk_level: RefactorRiskLevel,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
445pub enum RefactorRiskLevel {
446    Low,
447    Medium,
448    High,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
452pub struct RefactorCandidate {
453    pub id: String,
454    pub candidate_hash: String,
455    pub recipe: RefactorRecipe,
456    pub title: String,
457    pub rationale: String,
458    pub file: String,
459    pub line: usize,
460    pub risk: RefactorRiskLevel,
461    pub status: RefactorCandidateStatus,
462    pub tier: RecipeTier,
463    pub required_evidence: EvidenceGrade,
464    pub evidence_satisfied: bool,
465    #[serde(default)]
466    pub evidence_context: CandidateEvidenceContext,
467    #[serde(default)]
468    pub autonomy: CandidateAutonomyDecision,
469    pub public_api_impact: bool,
470    pub apply_command: Option<String>,
471    pub required_gates: Vec<String>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
475pub enum RefactorCandidateStatus {
476    ApplyViaImprove,
477    PlanOnly,
478    NeedsHumanDesign,
479}
480
481#[derive(
482    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
483)]
484pub enum RecipeTier {
485    Tier1,
486    Tier2,
487    Tier3,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
491pub enum RefactorRecipe {
492    BorrowParameterTightening,
493    ClonePressureReview,
494    ContextualErrorHardening,
495    ErrorContextPropagation,
496    ExtractFunctionCandidate,
497    IteratorCloned,
498    LenCheckIsEmpty,
499    LongFunctionReview,
500    MustUsePublicReturn,
501    OptionContextPropagation,
502    RepeatedStringLiteralConst,
503    SecurityBoundaryReview,
504    SplitModuleCandidate,
505    BoundaryValidationReview,
506    PublicApiReview,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
510pub struct RecipeCatalog {
511    pub schema_version: String,
512    pub recipes: Vec<RecipeSpec>,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
516pub struct RecipeSpec {
517    pub id: String,
518    pub recipe: RefactorRecipe,
519    pub tier: RecipeTier,
520    pub required_evidence: EvidenceGrade,
521    pub executable: bool,
522    pub risk: RefactorRiskLevel,
523    pub mutation_path: String,
524    pub description: String,
525}
526
527#[derive(Debug, Clone)]
528pub struct EvolutionScorecardConfig {
529    pub target: Option<PathBuf>,
530    pub policy_path: Option<PathBuf>,
531    pub behavior_spec_path: Option<PathBuf>,
532    pub max_files: usize,
533}
534
535impl Default for EvolutionScorecardConfig {
536    fn default() -> Self {
537        Self {
538            target: None,
539            policy_path: None,
540            behavior_spec_path: None,
541            max_files: 250,
542        }
543    }
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
547pub struct EvolutionScorecard {
548    pub schema_version: String,
549    pub scorecard_id: String,
550    pub root: String,
551    pub target: Option<String>,
552    pub readiness: AutonomyReadiness,
553    pub map: CodebaseMap,
554    pub plan: RefactorPlan,
555    pub recipes: RecipeCatalog,
556    pub next_commands: Vec<String>,
557    pub artifact_path: Option<String>,
558}
559
560pub fn recipe_catalog() -> RecipeCatalog {
561    macro_rules! spec {
562        (
563            $id:expr,
564            $recipe:expr,
565            $tier:expr,
566            $required_evidence:expr,
567            $executable:expr,
568            $risk:expr,
569            $mutation_path:expr,
570            $description:expr $(,)?
571        ) => {
572            RecipeSpec {
573                id: $id.to_string(),
574                recipe: $recipe,
575                tier: $tier,
576                required_evidence: $required_evidence,
577                executable: $executable,
578                risk: $risk,
579                mutation_path: $mutation_path.to_string(),
580                description: $description.to_string(),
581            }
582        };
583    }
584
585    RecipeCatalog {
586        schema_version: "0.9".to_string(),
587        recipes: vec![
588            spec!(
589                "contextual-error-hardening",
590                RefactorRecipe::ContextualErrorHardening,
591                RecipeTier::Tier1,
592                EvidenceGrade::Compiled,
593                true,
594                RefactorRiskLevel::Low,
595                "hardening transaction",
596                "Replace panic-prone Result unwraps with contextual errors.",
597            ),
598            spec!(
599                "error-context-propagation",
600                RefactorRecipe::ErrorContextPropagation,
601                RecipeTier::Tier1,
602                EvidenceGrade::Compiled,
603                true,
604                RefactorRiskLevel::Low,
605                "hardening transaction",
606                "Add context to boundary errors without changing public behavior.",
607            ),
608            spec!(
609                "borrow-parameter-tightening",
610                RefactorRecipe::BorrowParameterTightening,
611                RecipeTier::Tier1,
612                EvidenceGrade::Compiled,
613                true,
614                RefactorRiskLevel::Low,
615                "hardening transaction",
616                "Prefer borrowed slice/string parameters in private functions.",
617            ),
618            spec!(
619                "iterator-cloned-cleanup",
620                RefactorRecipe::IteratorCloned,
621                RecipeTier::Tier1,
622                EvidenceGrade::Compiled,
623                true,
624                RefactorRiskLevel::Low,
625                "hardening transaction",
626                "Move cloned calls to the narrower iterator position when mechanical.",
627            ),
628            spec!(
629                "option-context-propagation",
630                RefactorRecipe::OptionContextPropagation,
631                RecipeTier::Tier2,
632                EvidenceGrade::Covered,
633                true,
634                RefactorRiskLevel::Low,
635                "hardening transaction with covered evidence",
636                "Convert Option ok_or string boundaries to anyhow Context under coverage gates.",
637            ),
638            spec!(
639                "len-check-is-empty",
640                RefactorRecipe::LenCheckIsEmpty,
641                RecipeTier::Tier2,
642                EvidenceGrade::Covered,
643                true,
644                RefactorRiskLevel::Low,
645                "hardening transaction with covered evidence",
646                "Convert zero-length comparisons to is_empty for clarity.",
647            ),
648            spec!(
649                "repeated-string-literal-const",
650                RefactorRecipe::RepeatedStringLiteralConst,
651                RecipeTier::Tier2,
652                EvidenceGrade::Covered,
653                true,
654                RefactorRiskLevel::Low,
655                "hardening transaction with covered evidence",
656                "Extract repeated local string literals into a constant.",
657            ),
658            spec!(
659                "clone-pressure-review",
660                RefactorRecipe::ClonePressureReview,
661                RecipeTier::Tier3,
662                EvidenceGrade::Hardened,
663                false,
664                RefactorRiskLevel::Medium,
665                "plan only",
666                "Identify clone-heavy code that needs semantic review before rewriting.",
667            ),
668            spec!(
669                "extract-function",
670                RefactorRecipe::ExtractFunctionCandidate,
671                RecipeTier::Tier2,
672                EvidenceGrade::Covered,
673                false,
674                RefactorRiskLevel::Medium,
675                "plan only",
676                "Stage long functions for behavior-gated extraction.",
677            ),
678            spec!(
679                "split-module",
680                RefactorRecipe::SplitModuleCandidate,
681                RecipeTier::Tier2,
682                EvidenceGrade::Covered,
683                false,
684                RefactorRiskLevel::Medium,
685                "plan only",
686                "Stage oversized modules for human-reviewed decomposition.",
687            ),
688            spec!(
689                "security-boundary-review",
690                RefactorRecipe::SecurityBoundaryReview,
691                RecipeTier::Tier2,
692                EvidenceGrade::Tested,
693                false,
694                RefactorRiskLevel::High,
695                "plan only",
696                "Surface process, unsafe, and boundary risks before autonomous work expands.",
697            ),
698        ],
699    }
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
703pub struct RefactorApplyRun {
704    pub schema_version: String,
705    pub root: String,
706    pub plan_path: String,
707    pub plan_id: String,
708    pub plan_hash: String,
709    pub candidate_id: String,
710    pub candidate_hash: Option<String>,
711    pub mode: RefactorApplyMode,
712    pub status: RefactorApplyStatus,
713    pub public_api_impact_allowed: bool,
714    pub stale_files: Vec<StaleSourceFile>,
715    pub hardening_run: Option<HardeningRun>,
716    pub note: String,
717    pub artifact_path: Option<String>,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
721pub struct RefactorBatchApplyRun {
722    pub schema_version: String,
723    pub root: String,
724    pub plan_path: String,
725    pub plan_id: String,
726    pub plan_hash: String,
727    pub mode: RefactorApplyMode,
728    pub status: RefactorBatchApplyStatus,
729    pub public_api_impact_allowed: bool,
730    pub max_candidates: usize,
731    pub requested_candidates: usize,
732    pub executed_candidates: usize,
733    pub skipped_candidates: usize,
734    pub steps: Vec<RefactorBatchCandidateRun>,
735    pub note: String,
736    pub artifact_path: Option<String>,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
740pub struct RefactorBatchCandidateRun {
741    pub candidate_id: String,
742    pub candidate_hash: Option<String>,
743    pub file: String,
744    pub status: RefactorApplyStatus,
745    pub stale_file: Option<StaleSourceFile>,
746    pub hardening_run: Option<HardeningRun>,
747    pub note: String,
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
751pub enum RefactorApplyMode {
752    Review,
753    Apply,
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
757pub enum RefactorBatchApplyStatus {
758    Reviewed,
759    Applied,
760    PartiallyApplied,
761    Rejected,
762    StalePlan,
763    NoExecutableCandidates,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
767pub enum RefactorApplyStatus {
768    Reviewed,
769    Applied,
770    Rejected,
771    StalePlan,
772    Unsupported,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
776pub struct StaleSourceFile {
777    pub file: String,
778    pub expected_hash: String,
779    pub actual_hash: String,
780}
781
782pub fn build_refactor_plan(
783    root: &Path,
784    artifact_root: Option<&Path>,
785    config: &RefactorPlanConfig,
786) -> anyhow::Result<RefactorPlan> {
787    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
788    let refactor = analyze_refactor(
789        &root,
790        RefactorAnalyzeConfig {
791            target: config.target.as_deref(),
792            max_files: config.max_files,
793        },
794    )?;
795    let measured_evidence = load_latest_evidence_for_root(artifact_root, &root)?;
796    let hardening = analyze_hardening(
797        &root,
798        HardeningAnalyzeConfig {
799            target: config.target.as_deref(),
800            max_files: config.max_files,
801            max_recipe_tier: measured_hardening_tier(measured_evidence.as_ref()),
802            evidence_depth: hardening_depth_for_evidence(measured_evidence.as_ref()),
803        },
804    )?;
805    let policy = load_project_policy(&root, config.policy_path.as_deref())?;
806    let audit_scope = audit_scope_path(&root, config.target.as_deref());
807    let audit = audit_agent(&audit_scope)?;
808    let security = security_posture_summary(&audit.findings);
809    let workspace = workspace_summary(&root);
810    let behavior_spec = config
811        .behavior_spec_path
812        .as_ref()
813        .map(|path| path.display().to_string());
814    let capability_gates = capability_gates();
815    let evidence = summarize_evidence(
816        &workspace,
817        &refactor.files,
818        &capability_gates,
819        config.behavior_spec_path.is_some(),
820        measured_evidence.as_ref(),
821    );
822    let impact = summarize_impact(
823        &refactor.files,
824        refactor.module_edges.len(),
825        &hardening.findings,
826        hardening.changes.len(),
827    );
828    let mut candidates = Vec::new();
829    candidates.extend(hardening_candidates(
830        &hardening.findings,
831        config,
832        &evidence,
833        measured_evidence.as_ref(),
834    ));
835    candidates.extend(structural_candidates(
836        &refactor.files,
837        &evidence,
838        measured_evidence.as_ref(),
839    ));
840    candidates.extend(security_candidates(
841        &audit.findings,
842        &evidence,
843        measured_evidence.as_ref(),
844    ));
845    annotate_candidate_autonomy(&mut candidates, &evidence, &security);
846    let autonomy = autonomy_readiness(&evidence, &security, &candidates);
847    for candidate in &mut candidates {
848        candidate.candidate_hash = candidate_hash(candidate);
849    }
850    candidates.sort_by(|left, right| left.id.cmp(&right.id));
851    let source_snapshots = source_snapshots(&root, &refactor.files)?;
852
853    let required_gates = required_gates(config.behavior_spec_path.is_some());
854    let non_goals = vec![
855        "No broad API-changing refactors without explicit human allowance.".to_string(),
856        "No public API changes without explicit human review.".to_string(),
857        "No plan candidate may bypass improve/apply validation gates.".to_string(),
858    ];
859
860    let plan_id = plan_id(&root, config, &impact, &candidates);
861    let mut plan = RefactorPlan {
862        schema_version: "0.9".to_string(),
863        plan_id,
864        plan_hash: String::new(),
865        root: root.display().to_string(),
866        target: config
867            .target
868            .as_ref()
869            .map(|path| path.display().to_string()),
870        workspace,
871        policy,
872        behavior_spec,
873        evidence,
874        measured_evidence: measured_evidence.as_ref().map(EvidenceArtifactRef::from),
875        security,
876        autonomy,
877        impact,
878        source_snapshots,
879        files: refactor.files,
880        module_edges: refactor.module_edges,
881        candidates,
882        required_gates,
883        non_goals,
884        artifact_path: None,
885    };
886    plan.plan_hash = refactor_plan_hash(&plan);
887
888    if let Some(artifact_root) = artifact_root {
889        let path = persist_refactor_plan(artifact_root, &plan)?;
890        plan.artifact_path = Some(path.display().to_string());
891        std::fs::write(&path, serde_json::to_string_pretty(&plan)?)?;
892    }
893
894    Ok(plan)
895}
896
897pub fn build_codebase_map(
898    root: &Path,
899    artifact_root: Option<&Path>,
900    config: &CodebaseMapConfig,
901) -> anyhow::Result<CodebaseMap> {
902    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
903    let refactor = analyze_refactor(
904        &root,
905        RefactorAnalyzeConfig {
906            target: config.target.as_deref(),
907            max_files: config.max_files,
908        },
909    )?;
910    let measured_evidence = load_latest_evidence_for_root(artifact_root, &root)?;
911    let hardening = analyze_hardening(
912        &root,
913        HardeningAnalyzeConfig {
914            target: config.target.as_deref(),
915            max_files: config.max_files,
916            max_recipe_tier: measured_hardening_tier(measured_evidence.as_ref()),
917            evidence_depth: hardening_depth_for_evidence(measured_evidence.as_ref()),
918        },
919    )?;
920    let policy = load_project_policy(&root, config.policy_path.as_deref())?;
921    let audit_scope = audit_scope_path(&root, config.target.as_deref());
922    let audit = audit_agent(&audit_scope)?;
923    let security = security_posture_summary(&audit.findings);
924    let workspace = workspace_summary(&root);
925    let behavior_spec = config
926        .behavior_spec_path
927        .as_ref()
928        .map(|path| path.display().to_string());
929    let capability_gates = capability_gates();
930    let evidence = summarize_evidence(
931        &workspace,
932        &refactor.files,
933        &capability_gates,
934        config.behavior_spec_path.is_some(),
935        measured_evidence.as_ref(),
936    );
937    let impact = summarize_impact(
938        &refactor.files,
939        refactor.module_edges.len(),
940        &hardening.findings,
941        hardening.changes.len(),
942    );
943    let mut readiness_candidates = Vec::new();
944    readiness_candidates.extend(hardening_candidates(
945        &hardening.findings,
946        &RefactorPlanConfig {
947            target: config.target.clone(),
948            policy_path: config.policy_path.clone(),
949            behavior_spec_path: config.behavior_spec_path.clone(),
950            max_files: config.max_files,
951        },
952        &evidence,
953        measured_evidence.as_ref(),
954    ));
955    readiness_candidates.extend(structural_candidates(
956        &refactor.files,
957        &evidence,
958        measured_evidence.as_ref(),
959    ));
960    readiness_candidates.extend(security_candidates(
961        &audit.findings,
962        &evidence,
963        measured_evidence.as_ref(),
964    ));
965    annotate_candidate_autonomy(&mut readiness_candidates, &evidence, &security);
966    let autonomy = autonomy_readiness(&evidence, &security, &readiness_candidates);
967    let quality = summarize_quality(&refactor.files, &hardening.findings, &impact, &security);
968    let recommended_actions =
969        recommended_actions(&quality, &impact, &capability_gates, &evidence, &security);
970    let map_id = codebase_map_id(&root, config, &quality, &impact);
971    let mut map = CodebaseMap {
972        schema_version: "0.9".to_string(),
973        map_id,
974        map_hash: String::new(),
975        root: root.display().to_string(),
976        target: config
977            .target
978            .as_ref()
979            .map(|path| path.display().to_string()),
980        workspace,
981        policy,
982        behavior_spec,
983        evidence,
984        measured_evidence: measured_evidence.as_ref().map(EvidenceArtifactRef::from),
985        security,
986        autonomy,
987        quality,
988        capability_gates,
989        impact,
990        files: refactor.files,
991        module_edges: refactor.module_edges,
992        findings: hardening.findings,
993        recommended_actions,
994        artifact_path: None,
995    };
996    map.map_hash = codebase_map_hash(&map);
997
998    if let Some(artifact_root) = artifact_root {
999        let path = persist_codebase_map(artifact_root, &map)?;
1000        map.artifact_path = Some(path.display().to_string());
1001        std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
1002    }
1003
1004    Ok(map)
1005}
1006
1007pub fn build_evolution_scorecard(
1008    root: &Path,
1009    artifact_root: Option<&Path>,
1010    config: &EvolutionScorecardConfig,
1011) -> anyhow::Result<EvolutionScorecard> {
1012    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1013    let map = build_codebase_map(
1014        &root,
1015        artifact_root,
1016        &CodebaseMapConfig {
1017            target: config.target.clone(),
1018            policy_path: config.policy_path.clone(),
1019            behavior_spec_path: config.behavior_spec_path.clone(),
1020            max_files: config.max_files,
1021        },
1022    )?;
1023    let plan = build_refactor_plan(
1024        &root,
1025        artifact_root,
1026        &RefactorPlanConfig {
1027            target: config.target.clone(),
1028            policy_path: config.policy_path.clone(),
1029            behavior_spec_path: config.behavior_spec_path.clone(),
1030            max_files: config.max_files,
1031        },
1032    )?;
1033    let recipes = recipe_catalog();
1034    let readiness = plan.autonomy.clone();
1035    let next_commands = scorecard_next_commands(&readiness, &plan);
1036    let scorecard_id = evolution_scorecard_id(&root, config, &map, &plan);
1037    let mut scorecard = EvolutionScorecard {
1038        schema_version: "0.9".to_string(),
1039        scorecard_id,
1040        root: root.display().to_string(),
1041        target: config
1042            .target
1043            .as_ref()
1044            .map(|path| path.display().to_string()),
1045        readiness,
1046        map,
1047        plan,
1048        recipes,
1049        next_commands,
1050        artifact_path: None,
1051    };
1052
1053    if let Some(artifact_root) = artifact_root {
1054        let path = persist_evolution_scorecard(artifact_root, &scorecard)?;
1055        scorecard.artifact_path = Some(path.display().to_string());
1056        std::fs::write(&path, serde_json::to_string_pretty(&scorecard)?)?;
1057    }
1058
1059    Ok(scorecard)
1060}
1061
1062pub fn run_autopilot(
1063    root: &Path,
1064    artifact_root: Option<&Path>,
1065    config: &AutopilotConfig,
1066) -> anyhow::Result<AutopilotRun> {
1067    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1068    let map_config = CodebaseMapConfig {
1069        target: config.target.clone(),
1070        policy_path: config.policy_path.clone(),
1071        behavior_spec_path: config.behavior_spec_path.clone(),
1072        max_files: config.max_files,
1073    };
1074    let before_map = build_codebase_map(&root, artifact_root, &map_config)?;
1075    let evidence = before_map.evidence.clone();
1076    let quality_before = before_map.quality.clone();
1077    let mode = if config.apply {
1078        RefactorApplyMode::Apply
1079    } else {
1080        RefactorApplyMode::Review
1081    };
1082    let mut run = AutopilotRun {
1083        schema_version: "0.9".to_string(),
1084        run_id: autopilot_run_id(&root, config, &before_map),
1085        root: root.display().to_string(),
1086        target: config
1087            .target
1088            .as_ref()
1089            .map(|path| path.display().to_string()),
1090        mode,
1091        status: AutopilotStatus::NoExecutableCandidates,
1092        budget_seconds: config.budget.map(|duration| duration.as_secs()),
1093        max_passes: config.max_passes,
1094        max_candidates_per_pass: config.max_candidates,
1095        quality_before,
1096        quality_after: None,
1097        evidence,
1098        measured_evidence: before_map.measured_evidence.clone(),
1099        execution_summary: AutopilotExecutionSummary {
1100            plans_created: 0,
1101            executable_candidates_seen: 0,
1102            validated_transactions: 0,
1103            applied_transactions: 0,
1104            blocked_or_plan_only_candidates: 0,
1105            evidence_grade: before_map.evidence.grade,
1106            analysis_depth: before_map.evidence.analysis_depth.clone(),
1107        },
1108        passes: Vec::new(),
1109        total_planned_candidates: 0,
1110        total_executed_candidates: 0,
1111        total_skipped_candidates: 0,
1112        budget_exhausted: false,
1113        note: String::new(),
1114        artifact_path: None,
1115    };
1116
1117    let started_at = std::time::Instant::now();
1118    let pass_count = config.max_passes.max(1);
1119    for pass_index in 1..=pass_count {
1120        if config
1121            .budget
1122            .is_some_and(|budget| started_at.elapsed() >= budget)
1123        {
1124            run.budget_exhausted = true;
1125            break;
1126        }
1127        let plan = build_refactor_plan(
1128            &root,
1129            artifact_root,
1130            &RefactorPlanConfig {
1131                target: config.target.clone(),
1132                policy_path: config.policy_path.clone(),
1133                behavior_spec_path: config.behavior_spec_path.clone(),
1134                max_files: config.max_files,
1135            },
1136        )?;
1137        let executable = count_executable_candidates(
1138            &plan,
1139            config.allow_public_api_impact,
1140            config.max_candidates,
1141            config.max_tier,
1142            config.min_evidence,
1143        );
1144        run.total_planned_candidates += plan.candidates.len();
1145
1146        let mut pass = AutopilotPass {
1147            pass_index,
1148            plan_id: plan.plan_id.clone(),
1149            plan_hash: plan.plan_hash.clone(),
1150            plan_artifact_path: plan.artifact_path.clone(),
1151            planned_candidates: plan.candidates.len(),
1152            executable_candidates: executable,
1153            batch: None,
1154            status: AutopilotPassStatus::Planned,
1155            note: String::new(),
1156        };
1157
1158        if executable == 0 {
1159            pass.status = AutopilotPassStatus::NoExecutableCandidates;
1160            pass.note = "no executable low-risk candidates remain for this pass".to_string();
1161            run.passes.push(pass);
1162            break;
1163        }
1164
1165        let Some(plan_path) = plan.artifact_path.as_ref() else {
1166            pass.status = AutopilotPassStatus::Rejected;
1167            pass.note = "autopilot requires persisted plan artifacts before execution".to_string();
1168            run.passes.push(pass);
1169            break;
1170        };
1171
1172        let mut validation_timeout = config.validation_timeout;
1173        if let Some(budget) = config.budget {
1174            let Some(remaining) = budget.checked_sub(started_at.elapsed()) else {
1175                run.budget_exhausted = true;
1176                pass.status = AutopilotPassStatus::NoExecutableCandidates;
1177                pass.note = "budget exhausted before execution could start".to_string();
1178                run.passes.push(pass);
1179                break;
1180            };
1181            if remaining.is_zero() {
1182                run.budget_exhausted = true;
1183                pass.status = AutopilotPassStatus::NoExecutableCandidates;
1184                pass.note = "budget exhausted before execution could start".to_string();
1185                run.passes.push(pass);
1186                break;
1187            }
1188            validation_timeout = validation_timeout.min(remaining);
1189        }
1190
1191        let batch = apply_refactor_plan_batch(
1192            &root,
1193            artifact_root,
1194            &RefactorBatchApplyConfig {
1195                plan_path: PathBuf::from(plan_path),
1196                apply: config.apply,
1197                allow_public_api_impact: config.allow_public_api_impact,
1198                validation_timeout,
1199                max_candidates: config.max_candidates,
1200                max_tier: config.max_tier,
1201                min_evidence: config.min_evidence,
1202            },
1203        )?;
1204        if config
1205            .budget
1206            .is_some_and(|budget| started_at.elapsed() >= budget)
1207        {
1208            run.budget_exhausted = true;
1209        }
1210        run.total_executed_candidates += batch.executed_candidates;
1211        run.total_skipped_candidates += batch.skipped_candidates;
1212        pass.status = autopilot_pass_status(&batch.status);
1213        pass.note = batch.note.clone();
1214        let should_stop = !config.apply
1215            || matches!(
1216                batch.status,
1217                RefactorBatchApplyStatus::Rejected
1218                    | RefactorBatchApplyStatus::StalePlan
1219                    | RefactorBatchApplyStatus::NoExecutableCandidates
1220                    | RefactorBatchApplyStatus::PartiallyApplied
1221            )
1222            || batch.executed_candidates == 0;
1223        pass.batch = Some(batch);
1224        run.passes.push(pass);
1225        if should_stop {
1226            break;
1227        }
1228    }
1229
1230    let after_map = if config.apply && run.total_executed_candidates > 0 {
1231        Some(build_codebase_map(&root, artifact_root, &map_config)?)
1232    } else {
1233        None
1234    };
1235    run.quality_after = after_map.map(|map| map.quality);
1236    run.status = autopilot_status(config.apply, &run.passes, run.total_executed_candidates);
1237    run.note = autopilot_note(&run);
1238    run.execution_summary = autopilot_execution_summary(&run);
1239    persist_autopilot_run(artifact_root, run)
1240}
1241
1242pub fn apply_refactor_plan_candidate(
1243    root: &Path,
1244    artifact_root: Option<&Path>,
1245    config: &RefactorApplyConfig,
1246) -> anyhow::Result<RefactorApplyRun> {
1247    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1248    let plan_content = std::fs::read_to_string(&config.plan_path)?;
1249    let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
1250    let mode = if config.apply {
1251        RefactorApplyMode::Apply
1252    } else {
1253        RefactorApplyMode::Review
1254    };
1255    let mut run = RefactorApplyRun {
1256        schema_version: "0.9".to_string(),
1257        root: root.display().to_string(),
1258        plan_path: config.plan_path.display().to_string(),
1259        plan_id: plan.plan_id.clone(),
1260        plan_hash: plan.plan_hash.clone(),
1261        candidate_id: config.candidate_id.clone(),
1262        candidate_hash: None,
1263        mode,
1264        status: RefactorApplyStatus::Rejected,
1265        public_api_impact_allowed: config.allow_public_api_impact,
1266        stale_files: Vec::new(),
1267        hardening_run: None,
1268        note: String::new(),
1269        artifact_path: None,
1270    };
1271
1272    let actual_plan_hash = refactor_plan_hash(&plan);
1273    if actual_plan_hash != plan.plan_hash {
1274        run.status = RefactorApplyStatus::Rejected;
1275        run.note = format!(
1276            "plan hash mismatch: expected {} but recomputed {}",
1277            plan.plan_hash, actual_plan_hash
1278        );
1279        return persist_apply_run(artifact_root, run);
1280    }
1281
1282    let stale_files = stale_source_files(&root, &plan.source_snapshots)?;
1283    if !stale_files.is_empty() {
1284        run.status = RefactorApplyStatus::StalePlan;
1285        run.stale_files = stale_files;
1286        run.note =
1287            "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
1288        return persist_apply_run(artifact_root, run);
1289    }
1290
1291    let Some(candidate) = plan
1292        .candidates
1293        .iter()
1294        .find(|candidate| candidate.id == config.candidate_id)
1295    else {
1296        run.status = RefactorApplyStatus::Rejected;
1297        run.note = "candidate id was not found in the refactor plan".to_string();
1298        return persist_apply_run(artifact_root, run);
1299    };
1300    run.candidate_hash = Some(candidate.candidate_hash.clone());
1301
1302    let actual_candidate_hash = candidate_hash(candidate);
1303    if actual_candidate_hash != candidate.candidate_hash {
1304        run.status = RefactorApplyStatus::Rejected;
1305        run.note = format!(
1306            "candidate hash mismatch: expected {} but recomputed {}",
1307            candidate.candidate_hash, actual_candidate_hash
1308        );
1309        return persist_apply_run(artifact_root, run);
1310    }
1311
1312    if candidate.public_api_impact && !config.allow_public_api_impact {
1313        run.status = RefactorApplyStatus::Rejected;
1314        run.note = "candidate touches public API impact area; pass --allow-public-api-impact after human review".to_string();
1315        return persist_apply_run(artifact_root, run);
1316    }
1317
1318    if !candidate.evidence_satisfied {
1319        run.status = RefactorApplyStatus::Unsupported;
1320        run.note = format!(
1321            "candidate requires {:?} evidence but plan evidence is {:?}",
1322            candidate.required_evidence, plan.evidence.grade
1323        );
1324        return persist_apply_run(artifact_root, run);
1325    }
1326
1327    if candidate.autonomy.decision != AutonomyDecision::Allowed {
1328        run.status = RefactorApplyStatus::Unsupported;
1329        run.note = format!(
1330            "candidate autonomy decision is {:?}: {}",
1331            candidate.autonomy.decision,
1332            candidate.autonomy.reasons.join("; ")
1333        );
1334        return persist_apply_run(artifact_root, run);
1335    }
1336
1337    if candidate.status != RefactorCandidateStatus::ApplyViaImprove
1338        || !is_supported_mechanical_recipe(&candidate.recipe)
1339    {
1340        run.status = RefactorApplyStatus::Unsupported;
1341        run.note = "candidate is plan-only; no executable recipe is available yet".to_string();
1342        return persist_apply_run(artifact_root, run);
1343    }
1344
1345    let hardening = run_hardening(
1346        &root,
1347        artifact_root,
1348        &HardeningConfig {
1349            target: Some(PathBuf::from(&candidate.file)),
1350            policy_path: plan
1351                .policy
1352                .as_ref()
1353                .map(|policy| PathBuf::from(policy.path.clone())),
1354            behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
1355            apply: config.apply,
1356            max_files: 1,
1357            max_recipe_tier: recipe_tier_number(candidate.tier),
1358            evidence_depth: hardening_depth_for_grade(candidate.required_evidence),
1359            validation_timeout: config.validation_timeout,
1360        },
1361    )?;
1362
1363    run.status = if config.apply {
1364        if hardening.outcome.applied {
1365            RefactorApplyStatus::Applied
1366        } else {
1367            RefactorApplyStatus::Rejected
1368        }
1369    } else if hardening.outcome.isolated_validation_passed {
1370        RefactorApplyStatus::Reviewed
1371    } else {
1372        RefactorApplyStatus::Rejected
1373    };
1374    run.note = format!(
1375        "executed candidate through hardening transaction; hardening status: {:?}",
1376        hardening.outcome.status
1377    );
1378    run.hardening_run = Some(hardening);
1379    persist_apply_run(artifact_root, run)
1380}
1381
1382pub fn apply_refactor_plan_batch(
1383    root: &Path,
1384    artifact_root: Option<&Path>,
1385    config: &RefactorBatchApplyConfig,
1386) -> anyhow::Result<RefactorBatchApplyRun> {
1387    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1388    let plan_content = std::fs::read_to_string(&config.plan_path)?;
1389    let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
1390    let mode = if config.apply {
1391        RefactorApplyMode::Apply
1392    } else {
1393        RefactorApplyMode::Review
1394    };
1395    let mut run = RefactorBatchApplyRun {
1396        schema_version: "0.9".to_string(),
1397        root: root.display().to_string(),
1398        plan_path: config.plan_path.display().to_string(),
1399        plan_id: plan.plan_id.clone(),
1400        plan_hash: plan.plan_hash.clone(),
1401        mode,
1402        status: RefactorBatchApplyStatus::Rejected,
1403        public_api_impact_allowed: config.allow_public_api_impact,
1404        max_candidates: config.max_candidates,
1405        requested_candidates: 0,
1406        executed_candidates: 0,
1407        skipped_candidates: 0,
1408        steps: Vec::new(),
1409        note: String::new(),
1410        artifact_path: None,
1411    };
1412
1413    let actual_plan_hash = refactor_plan_hash(&plan);
1414    if actual_plan_hash != plan.plan_hash {
1415        run.status = RefactorBatchApplyStatus::Rejected;
1416        run.note = format!(
1417            "plan hash mismatch: expected {} but recomputed {}",
1418            plan.plan_hash, actual_plan_hash
1419        );
1420        return persist_batch_apply_run(artifact_root, run);
1421    }
1422
1423    let initial_stale_files = stale_source_files(&root, &plan.source_snapshots)?;
1424    if !initial_stale_files.is_empty() {
1425        run.status = RefactorBatchApplyStatus::StalePlan;
1426        run.steps = initial_stale_files
1427            .into_iter()
1428            .map(|stale| RefactorBatchCandidateRun {
1429                candidate_id: String::new(),
1430                candidate_hash: None,
1431                file: stale.file.clone(),
1432                status: RefactorApplyStatus::StalePlan,
1433                stale_file: Some(stale),
1434                hardening_run: None,
1435                note: "source snapshot no longer matches the workspace".to_string(),
1436            })
1437            .collect();
1438        run.note =
1439            "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
1440        return persist_batch_apply_run(artifact_root, run);
1441    }
1442
1443    let queue = executable_candidate_queue(&plan, config);
1444    run.requested_candidates = queue.len();
1445    if queue.is_empty() {
1446        run.status = RefactorBatchApplyStatus::NoExecutableCandidates;
1447        run.note = "no executable low-risk candidates were available in the plan".to_string();
1448        return persist_batch_apply_run(artifact_root, run);
1449    }
1450
1451    for candidate in queue {
1452        let mut step = RefactorBatchCandidateRun {
1453            candidate_id: candidate.id.clone(),
1454            candidate_hash: Some(candidate.candidate_hash.clone()),
1455            file: candidate.file.clone(),
1456            status: RefactorApplyStatus::Rejected,
1457            stale_file: None,
1458            hardening_run: None,
1459            note: String::new(),
1460        };
1461
1462        let actual_candidate_hash = candidate_hash(candidate);
1463        if actual_candidate_hash != candidate.candidate_hash {
1464            step.note = format!(
1465                "candidate hash mismatch: expected {} but recomputed {}",
1466                candidate.candidate_hash, actual_candidate_hash
1467            );
1468            run.skipped_candidates += 1;
1469            run.steps.push(step);
1470            if config.apply {
1471                break;
1472            }
1473            continue;
1474        }
1475
1476        if let Some(stale) = stale_file_for_candidate(&root, &plan, &candidate.file)? {
1477            step.status = RefactorApplyStatus::StalePlan;
1478            step.stale_file = Some(stale);
1479            step.note =
1480                "candidate source file changed after planning; re-run mdx-rust plan".to_string();
1481            run.skipped_candidates += 1;
1482            run.steps.push(step);
1483            if config.apply {
1484                break;
1485            }
1486            continue;
1487        }
1488
1489        let hardening = run_hardening(
1490            &root,
1491            artifact_root,
1492            &HardeningConfig {
1493                target: Some(PathBuf::from(&candidate.file)),
1494                policy_path: plan
1495                    .policy
1496                    .as_ref()
1497                    .map(|policy| PathBuf::from(policy.path.clone())),
1498                behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
1499                apply: config.apply,
1500                max_files: 1,
1501                max_recipe_tier: recipe_tier_number(candidate.tier),
1502                evidence_depth: hardening_depth_for_grade(candidate.required_evidence),
1503                validation_timeout: config.validation_timeout,
1504            },
1505        )?;
1506
1507        step.status = if config.apply {
1508            if hardening.outcome.applied {
1509                RefactorApplyStatus::Applied
1510            } else {
1511                RefactorApplyStatus::Rejected
1512            }
1513        } else if hardening.outcome.isolated_validation_passed {
1514            RefactorApplyStatus::Reviewed
1515        } else {
1516            RefactorApplyStatus::Rejected
1517        };
1518        step.note = format!(
1519            "executed candidate through hardening transaction; hardening status: {:?}",
1520            hardening.outcome.status
1521        );
1522        step.hardening_run = Some(hardening);
1523
1524        if matches!(
1525            step.status,
1526            RefactorApplyStatus::Reviewed | RefactorApplyStatus::Applied
1527        ) {
1528            run.executed_candidates += 1;
1529        } else {
1530            run.skipped_candidates += 1;
1531        }
1532
1533        let failed_apply_step = config.apply && step.status != RefactorApplyStatus::Applied;
1534        run.steps.push(step);
1535        if failed_apply_step {
1536            break;
1537        }
1538    }
1539
1540    run.status = batch_status(
1541        config.apply,
1542        run.executed_candidates,
1543        run.requested_candidates,
1544    );
1545    run.note = format!(
1546        "processed {} executable candidate(s); executed {}, skipped {}",
1547        run.requested_candidates, run.executed_candidates, run.skipped_candidates
1548    );
1549    persist_batch_apply_run(artifact_root, run)
1550}
1551
1552fn summarize_impact(
1553    files: &[RefactorFileSummary],
1554    module_edge_count: usize,
1555    findings: &[HardeningFinding],
1556    patchable_hardening_changes: usize,
1557) -> RefactorImpactSummary {
1558    let public_item_count = files.iter().map(|file| file.public_item_count).sum();
1559    let public_files = files
1560        .iter()
1561        .filter(|file| file.public_item_count > 0)
1562        .count();
1563    let oversized_files = files.iter().filter(|file| file.line_count >= 300).count();
1564    let oversized_functions = files
1565        .iter()
1566        .filter(|file| file.largest_function_lines >= 80)
1567        .count();
1568    let review_only_findings = findings.iter().filter(|finding| !finding.patchable).count();
1569    let risk_level = if public_item_count > 10 || oversized_files > 2 {
1570        RefactorRiskLevel::High
1571    } else if public_item_count > 0 || oversized_files > 0 || oversized_functions > 0 {
1572        RefactorRiskLevel::Medium
1573    } else {
1574        RefactorRiskLevel::Low
1575    };
1576
1577    RefactorImpactSummary {
1578        files_scanned: files.len(),
1579        public_item_count,
1580        public_files,
1581        module_edge_count,
1582        patchable_hardening_changes,
1583        review_only_findings,
1584        oversized_files,
1585        oversized_functions,
1586        risk_level,
1587    }
1588}
1589
1590fn summarize_quality(
1591    files: &[RefactorFileSummary],
1592    findings: &[HardeningFinding],
1593    impact: &RefactorImpactSummary,
1594    security: &SecurityPostureSummary,
1595) -> CodebaseQualitySummary {
1596    let patchable_findings = findings.iter().filter(|finding| finding.patchable).count();
1597    let review_only_findings = findings.len().saturating_sub(patchable_findings);
1598    let files_with_tests = files.iter().filter(|file| file.has_tests).count();
1599    let test_coverage_signal = if files.is_empty() {
1600        TestCoverageSignal::Unknown
1601    } else if files_with_tests > 0 {
1602        TestCoverageSignal::Present
1603    } else {
1604        TestCoverageSignal::Sparse
1605    };
1606
1607    let mut score = 0usize;
1608    score += patchable_findings.saturating_mul(8);
1609    score += review_only_findings.saturating_mul(4);
1610    score += impact.oversized_files.saturating_mul(10);
1611    score += impact.oversized_functions.saturating_mul(7);
1612    score += impact.public_files.saturating_mul(2);
1613    score += (100usize.saturating_sub(security.score as usize)) / 2;
1614    if test_coverage_signal == TestCoverageSignal::Sparse {
1615        score += 12;
1616    }
1617    let debt_score = score.min(100) as u8;
1618    let grade = if debt_score >= 70 {
1619        CodebaseQualityGrade::HighRisk
1620    } else if debt_score >= 35 {
1621        CodebaseQualityGrade::NeedsWork
1622    } else if debt_score >= 10 {
1623        CodebaseQualityGrade::Good
1624    } else {
1625        CodebaseQualityGrade::Excellent
1626    };
1627
1628    CodebaseQualitySummary {
1629        grade,
1630        debt_score,
1631        security_score: security.score,
1632        patchable_findings,
1633        review_only_findings,
1634        public_api_pressure: impact.public_item_count,
1635        oversized_files: impact.oversized_files,
1636        oversized_functions: impact.oversized_functions,
1637        test_coverage_signal,
1638    }
1639}
1640
1641fn summarize_evidence(
1642    workspace: &WorkspaceSummary,
1643    files: &[RefactorFileSummary],
1644    gates: &[CapabilityGate],
1645    has_behavior_spec: bool,
1646    measured: Option<&EvidenceRun>,
1647) -> EvidenceSummary {
1648    let has_tests = files.iter().any(|file| file.has_tests);
1649    let has_nextest = gates
1650        .iter()
1651        .any(|gate| gate.id == "nextest" && gate.available);
1652    let has_coverage_tool = gates
1653        .iter()
1654        .any(|gate| gate.id == "llvm-cov" && gate.available);
1655    let has_mutation_tool = gates
1656        .iter()
1657        .any(|gate| gate.id == "mutants" && gate.available);
1658
1659    let inferred_grade = if !workspace.cargo_metadata_available {
1660        EvidenceGrade::None
1661    } else if has_tests || has_behavior_spec || has_nextest {
1662        EvidenceGrade::Tested
1663    } else {
1664        EvidenceGrade::Compiled
1665    };
1666    let grade = measured.map(|run| run.grade).unwrap_or(inferred_grade);
1667    let max_autonomous_tier = max_tier_for_evidence(grade);
1668    let analysis_depth = measured
1669        .map(|run| run.analysis_depth.clone())
1670        .unwrap_or_else(|| analysis_depth_for_evidence(grade));
1671
1672    let mut signals = vec![
1673        EvidenceSignal {
1674            id: "cargo-metadata".to_string(),
1675            label: "Cargo metadata".to_string(),
1676            present: workspace.cargo_metadata_available,
1677            detail: if workspace.cargo_metadata_available {
1678                "workspace can be inspected and compile gates can run".to_string()
1679            } else {
1680                "no Cargo metadata was available for this target".to_string()
1681            },
1682        },
1683        EvidenceSignal {
1684            id: "tests-or-behavior-evals".to_string(),
1685            label: "Tests or behavior evals".to_string(),
1686            present: has_tests || has_behavior_spec,
1687            detail: if has_behavior_spec {
1688                "behavior eval spec was supplied".to_string()
1689            } else if has_tests {
1690                "at least one scanned file contains Rust test markers".to_string()
1691            } else {
1692                "no tests or behavior eval spec were detected for the scanned target".to_string()
1693            },
1694        },
1695        EvidenceSignal {
1696            id: "coverage-tool".to_string(),
1697            label: "Coverage tooling".to_string(),
1698            present: has_coverage_tool,
1699            detail: "cargo-llvm-cov availability is detected; run mdx-rust evidence --include-coverage to collect coverage evidence".to_string(),
1700        },
1701        EvidenceSignal {
1702            id: "mutation-tool".to_string(),
1703            label: "Mutation tooling".to_string(),
1704            present: has_mutation_tool,
1705            detail: "cargo-mutants availability is detected; run mdx-rust evidence --include-mutation to collect mutation evidence".to_string(),
1706        },
1707    ];
1708    if let Some(run) = measured {
1709        signals.push(EvidenceSignal {
1710            id: "measured-evidence".to_string(),
1711            label: "Measured evidence artifact".to_string(),
1712            present: true,
1713            detail: format!(
1714                "latest evidence run {} recorded {:?} evidence",
1715                run.run_id, run.grade
1716            ),
1717        });
1718    }
1719
1720    let mut unlock_suggestions = Vec::new();
1721    if grade == EvidenceGrade::None {
1722        unlock_suggestions.push(
1723            "Run mdx-rust from a Cargo workspace before allowing autonomous changes.".to_string(),
1724        );
1725    }
1726    if measured.is_none() && grade < EvidenceGrade::Tested {
1727        unlock_suggestions.push(
1728            "Add Rust tests or pass --eval-spec to unlock tested evidence for future recipes."
1729                .to_string(),
1730        );
1731    }
1732    if measured.is_none() {
1733        unlock_suggestions.push(
1734            "Run mdx-rust evidence to replace inferred evidence with measured test results."
1735                .to_string(),
1736        );
1737    }
1738    if !has_coverage_tool {
1739        unlock_suggestions
1740            .push("Install cargo-llvm-cov to prepare for covered Tier 2 recipe gates.".to_string());
1741    }
1742    if !has_mutation_tool {
1743        unlock_suggestions.push(
1744            "Install cargo-mutants to prepare for hardened Tier 2 and Tier 3 recipe gates."
1745                .to_string(),
1746        );
1747    }
1748
1749    EvidenceSummary {
1750        grade,
1751        max_autonomous_tier,
1752        analysis_depth,
1753        signals,
1754        profiled_files: measured.map(|run| run.file_profiles.len()).unwrap_or(0),
1755        unlocked_recipe_tiers: unlocked_recipe_tiers(grade),
1756        unlock_suggestions,
1757    }
1758}
1759
1760fn security_posture_summary(findings: &[AuditFinding]) -> SecurityPostureSummary {
1761    let mut summary = SecurityPostureSummary::default();
1762    for finding in findings {
1763        match finding.severity {
1764            AuditSeverity::High => summary.high += 1,
1765            AuditSeverity::Medium => summary.medium += 1,
1766            AuditSeverity::Low => summary.low += 1,
1767            AuditSeverity::Info => summary.info += 1,
1768        }
1769    }
1770    let penalty = summary.high.saturating_mul(25)
1771        + summary.medium.saturating_mul(12)
1772        + summary.low.saturating_mul(5);
1773    summary.score = 100usize.saturating_sub(penalty).min(100) as u8;
1774    summary.top_findings = findings
1775        .iter()
1776        .filter(|finding| finding.severity != AuditSeverity::Info)
1777        .take(5)
1778        .map(|finding| {
1779            let file = finding.file.as_deref().unwrap_or("<workspace>");
1780            let line = finding
1781                .line
1782                .map(|line| line.to_string())
1783                .unwrap_or_else(|| "?".to_string());
1784            format!(
1785                "{:?}: {} ({}:{})",
1786                finding.severity, finding.title, file, line
1787            )
1788        })
1789        .collect();
1790    summary
1791}
1792
1793fn annotate_candidate_autonomy(
1794    candidates: &mut [RefactorCandidate],
1795    evidence: &EvidenceSummary,
1796    security: &SecurityPostureSummary,
1797) {
1798    for candidate in candidates {
1799        candidate.autonomy = candidate_autonomy_decision(candidate, evidence, security);
1800    }
1801}
1802
1803fn candidate_autonomy_decision(
1804    candidate: &RefactorCandidate,
1805    evidence: &EvidenceSummary,
1806    security: &SecurityPostureSummary,
1807) -> CandidateAutonomyDecision {
1808    let mut reasons = Vec::new();
1809    if evidence.grade == EvidenceGrade::None {
1810        reasons.push("no usable evidence grade is available".to_string());
1811        return CandidateAutonomyDecision {
1812            decision: AutonomyDecision::Blocked,
1813            reasons,
1814        };
1815    }
1816    if security.high > 0 {
1817        reasons.push(
1818            "high-severity security finding requires human review before autonomous apply"
1819                .to_string(),
1820        );
1821        return CandidateAutonomyDecision {
1822            decision: AutonomyDecision::ReviewOnly,
1823            reasons,
1824        };
1825    }
1826    if !candidate.evidence_satisfied || candidate.required_evidence > evidence.grade {
1827        reasons.push(format!(
1828            "candidate requires {:?} evidence but target has {:?}",
1829            candidate.required_evidence, evidence.grade
1830        ));
1831        return CandidateAutonomyDecision {
1832            decision: AutonomyDecision::Blocked,
1833            reasons,
1834        };
1835    }
1836    if candidate.public_api_impact {
1837        reasons.push("public API impact requires explicit human allowance".to_string());
1838        return CandidateAutonomyDecision {
1839            decision: AutonomyDecision::ReviewOnly,
1840            reasons,
1841        };
1842    }
1843    if candidate.status != RefactorCandidateStatus::ApplyViaImprove {
1844        reasons.push("candidate is plan-only or needs human design".to_string());
1845        return CandidateAutonomyDecision {
1846            decision: AutonomyDecision::ReviewOnly,
1847            reasons,
1848        };
1849    }
1850    if !is_supported_mechanical_recipe(&candidate.recipe) {
1851        reasons.push("candidate has no supported executable recipe".to_string());
1852        return CandidateAutonomyDecision {
1853            decision: AutonomyDecision::ReviewOnly,
1854            reasons,
1855        };
1856    }
1857    if candidate.risk != RefactorRiskLevel::Low {
1858        reasons.push("only low-risk candidates are autonomous by default".to_string());
1859        return CandidateAutonomyDecision {
1860            decision: AutonomyDecision::ReviewOnly,
1861            reasons,
1862        };
1863    }
1864
1865    reasons.push("low-risk executable recipe satisfies current evidence gates".to_string());
1866    CandidateAutonomyDecision {
1867        decision: AutonomyDecision::Allowed,
1868        reasons,
1869    }
1870}
1871
1872fn autonomy_readiness(
1873    evidence: &EvidenceSummary,
1874    security: &SecurityPostureSummary,
1875    candidates: &[RefactorCandidate],
1876) -> AutonomyReadiness {
1877    let executable_candidates = candidates
1878        .iter()
1879        .filter(|candidate| candidate.autonomy.decision == AutonomyDecision::Allowed)
1880        .count();
1881    let review_only_candidates = candidates
1882        .iter()
1883        .filter(|candidate| candidate.autonomy.decision == AutonomyDecision::ReviewOnly)
1884        .count();
1885    let blocked_candidates = candidates
1886        .iter()
1887        .filter(|candidate| candidate.autonomy.decision == AutonomyDecision::Blocked)
1888        .count();
1889    let mut blockers = Vec::new();
1890    if evidence.grade == EvidenceGrade::None {
1891        blockers.push("no usable evidence grade is available".to_string());
1892    }
1893    if security.high > 0 {
1894        blockers.push("high-severity security findings require review first".to_string());
1895    }
1896    if executable_candidates == 0 {
1897        blockers.push("no low-risk executable candidates are currently allowed".to_string());
1898    }
1899
1900    let has_tier2_allowed = candidates.iter().any(|candidate| {
1901        candidate.autonomy.decision == AutonomyDecision::Allowed
1902            && candidate.tier >= RecipeTier::Tier2
1903    });
1904    let has_tier3_plan = candidates.iter().any(|candidate| {
1905        candidate.tier >= RecipeTier::Tier3
1906            && candidate.autonomy.decision != AutonomyDecision::Blocked
1907    });
1908    let grade = if evidence.grade == EvidenceGrade::None {
1909        AutonomyReadinessGrade::Blocked
1910    } else if executable_candidates > 0 && has_tier2_allowed {
1911        AutonomyReadinessGrade::Tier2Ready
1912    } else if executable_candidates > 0 {
1913        AutonomyReadinessGrade::Tier1Ready
1914    } else if has_tier3_plan {
1915        AutonomyReadinessGrade::Tier3Planning
1916    } else {
1917        AutonomyReadinessGrade::ReviewOnly
1918    };
1919    let max_safe_tier = candidates
1920        .iter()
1921        .filter(|candidate| candidate.autonomy.decision == AutonomyDecision::Allowed)
1922        .map(|candidate| candidate.tier)
1923        .max()
1924        .unwrap_or(RecipeTier::Tier1);
1925    let recommended_command = match grade {
1926        AutonomyReadinessGrade::Tier2Ready => Some(
1927            "mdx-rust evolve <target> --budget 10m --tier 2 --min-evidence covered".to_string(),
1928        ),
1929        AutonomyReadinessGrade::Tier1Ready => {
1930            Some("mdx-rust evolve <target> --budget 10m --tier 1".to_string())
1931        }
1932        AutonomyReadinessGrade::Tier3Planning => Some("mdx-rust plan <target> --json".to_string()),
1933        AutonomyReadinessGrade::ReviewOnly | AutonomyReadinessGrade::Blocked => {
1934            Some("mdx-rust map <target> --json".to_string())
1935        }
1936    };
1937
1938    AutonomyReadiness {
1939        grade,
1940        max_safe_tier,
1941        executable_candidates,
1942        review_only_candidates,
1943        blocked_candidates,
1944        blockers,
1945        recommended_command,
1946    }
1947}
1948
1949fn analysis_depth_for_evidence(grade: EvidenceGrade) -> EvidenceAnalysisDepth {
1950    match grade {
1951        EvidenceGrade::None => EvidenceAnalysisDepth::None,
1952        EvidenceGrade::Compiled => EvidenceAnalysisDepth::Mechanical,
1953        EvidenceGrade::Tested => EvidenceAnalysisDepth::BoundaryAware,
1954        EvidenceGrade::Covered | EvidenceGrade::Hardened | EvidenceGrade::Proven => {
1955            EvidenceAnalysisDepth::Structural
1956        }
1957    }
1958}
1959
1960fn unlocked_recipe_tiers(grade: EvidenceGrade) -> Vec<String> {
1961    let mut tiers = Vec::new();
1962    if grade >= EvidenceGrade::Compiled {
1963        tiers.push("Tier 1 executable mechanical recipes".to_string());
1964    }
1965    if grade >= EvidenceGrade::Tested {
1966        tiers.push("Tier 2 boundary review candidates".to_string());
1967    }
1968    if grade >= EvidenceGrade::Covered {
1969        tiers.push("Tier 2 structural mechanical recipes".to_string());
1970    }
1971    if grade >= EvidenceGrade::Hardened {
1972        tiers.push("Tier 3 semantic candidates in review".to_string());
1973    }
1974    tiers
1975}
1976
1977fn max_tier_for_evidence(grade: EvidenceGrade) -> u8 {
1978    match grade {
1979        EvidenceGrade::None => 0,
1980        EvidenceGrade::Compiled | EvidenceGrade::Tested => 1,
1981        EvidenceGrade::Covered => 2,
1982        EvidenceGrade::Hardened | EvidenceGrade::Proven => 3,
1983    }
1984}
1985
1986fn measured_hardening_tier(measured: Option<&EvidenceRun>) -> u8 {
1987    match measured.map(|run| run.grade) {
1988        Some(EvidenceGrade::Hardened | EvidenceGrade::Proven) => 3,
1989        Some(EvidenceGrade::Covered) => 2,
1990        _ => 1,
1991    }
1992}
1993
1994fn hardening_depth_for_evidence(measured: Option<&EvidenceRun>) -> HardeningEvidenceDepth {
1995    match measured.map(|run| run.grade) {
1996        Some(EvidenceGrade::Proven) => HardeningEvidenceDepth::Proven,
1997        Some(EvidenceGrade::Hardened) => HardeningEvidenceDepth::Hardened,
1998        Some(EvidenceGrade::Covered) => HardeningEvidenceDepth::Covered,
1999        Some(EvidenceGrade::Tested) => HardeningEvidenceDepth::Tested,
2000        _ => HardeningEvidenceDepth::Basic,
2001    }
2002}
2003
2004fn hardening_depth_for_grade(grade: EvidenceGrade) -> HardeningEvidenceDepth {
2005    match grade {
2006        EvidenceGrade::Proven => HardeningEvidenceDepth::Proven,
2007        EvidenceGrade::Hardened => HardeningEvidenceDepth::Hardened,
2008        EvidenceGrade::Covered => HardeningEvidenceDepth::Covered,
2009        EvidenceGrade::Tested => HardeningEvidenceDepth::Tested,
2010        EvidenceGrade::None | EvidenceGrade::Compiled => HardeningEvidenceDepth::Basic,
2011    }
2012}
2013
2014fn capability_gates() -> Vec<CapabilityGate> {
2015    vec![
2016        CapabilityGate {
2017            id: "nextest".to_string(),
2018            label: "cargo-nextest".to_string(),
2019            available: cargo_subcommand_exists("nextest"),
2020            command: "cargo nextest run".to_string(),
2021            purpose: "fast, isolated Rust test execution for behavior gates".to_string(),
2022        },
2023        CapabilityGate {
2024            id: "llvm-cov".to_string(),
2025            label: "cargo-llvm-cov".to_string(),
2026            available: cargo_subcommand_exists("llvm-cov"),
2027            command: "cargo llvm-cov".to_string(),
2028            purpose: "coverage evidence before broad autonomous refactoring".to_string(),
2029        },
2030        CapabilityGate {
2031            id: "mutants".to_string(),
2032            label: "cargo-mutants".to_string(),
2033            available: cargo_subcommand_exists("mutants"),
2034            command: "cargo mutants".to_string(),
2035            purpose: "mutation testing signal for high-value refactor targets".to_string(),
2036        },
2037        CapabilityGate {
2038            id: "semver-checks".to_string(),
2039            label: "cargo-semver-checks".to_string(),
2040            available: cargo_subcommand_exists("semver-checks"),
2041            command: "cargo semver-checks".to_string(),
2042            purpose: "public API compatibility gate for library refactors".to_string(),
2043        },
2044    ]
2045}
2046
2047fn recommended_actions(
2048    quality: &CodebaseQualitySummary,
2049    impact: &RefactorImpactSummary,
2050    gates: &[CapabilityGate],
2051    evidence: &EvidenceSummary,
2052    security: &SecurityPostureSummary,
2053) -> Vec<String> {
2054    let mut actions = Vec::new();
2055    if security.high > 0 || security.medium > 0 {
2056        actions.push(
2057            "Run mdx-rust audit and inspect security posture before broad autonomous apply."
2058                .to_string(),
2059        );
2060    }
2061    if quality.patchable_findings > 0 && evidence.grade >= EvidenceGrade::Compiled {
2062        actions.push(
2063            "Run mdx-rust autopilot --apply to execute low-risk Tier 1 mechanical hardening passes."
2064                .to_string(),
2065        );
2066    } else if quality.patchable_findings > 0 {
2067        actions.push(
2068            "Autonomous execution is blocked until this target has at least compiled evidence."
2069                .to_string(),
2070        );
2071    }
2072    if quality.review_only_findings > 0 {
2073        actions.push(
2074            "Review security-sensitive findings before enabling broader recipes.".to_string(),
2075        );
2076    }
2077    if impact.oversized_files > 0 || impact.oversized_functions > 0 {
2078        actions.push(
2079            "Use mdx-rust plan to stage larger module and function refactors behind behavior gates."
2080                .to_string(),
2081        );
2082    }
2083    if quality.public_api_pressure > 0
2084        && gates
2085            .iter()
2086            .any(|gate| gate.id == "semver-checks" && !gate.available)
2087    {
2088        actions.push(
2089            "Install cargo-semver-checks before allowing public API impacting refactors."
2090                .to_string(),
2091        );
2092    }
2093    if quality.test_coverage_signal == TestCoverageSignal::Sparse {
2094        actions.push(
2095            "Add a behavior eval spec or stronger Rust tests before broad autonomous apply."
2096                .to_string(),
2097        );
2098    }
2099    actions.extend(evidence.unlock_suggestions.iter().cloned());
2100    if actions.is_empty() {
2101        actions.push(
2102            "No immediate autonomous changes found. Keep policy and behavior gates current."
2103                .to_string(),
2104        );
2105    }
2106    actions
2107}
2108
2109fn cargo_subcommand_exists(name: &str) -> bool {
2110    let command = format!("cargo-{name}");
2111    let Some(path_var) = std::env::var_os("PATH") else {
2112        return false;
2113    };
2114    std::env::split_paths(&path_var).any(|dir| dir.join(&command).is_file())
2115}
2116
2117fn hardening_candidates(
2118    findings: &[HardeningFinding],
2119    config: &RefactorPlanConfig,
2120    evidence: &EvidenceSummary,
2121    measured: Option<&EvidenceRun>,
2122) -> Vec<RefactorCandidate> {
2123    findings
2124        .iter()
2125        .filter_map(|finding| {
2126            let file = finding.file.display().to_string();
2127            let required_evidence = required_evidence_for_hardening_strategy(&finding.strategy);
2128            let evidence_satisfied = evidence.grade >= required_evidence;
2129            let recipe = recipe_for_hardening_strategy(&finding.strategy);
2130            if !finding.patchable && !evidence_satisfied {
2131                return None;
2132            }
2133
2134            Some(RefactorCandidate {
2135                id: format!(
2136                    "plan-hardening-{}-{}-{}",
2137                    sanitize_id(&file),
2138                    sanitize_id(&format!("{:?}", finding.strategy)),
2139                    finding.line
2140                ),
2141                candidate_hash: String::new(),
2142                recipe,
2143                title: finding.title.clone(),
2144                rationale: if finding.patchable {
2145                    if required_evidence >= EvidenceGrade::Covered {
2146                        "Patchable Tier 2 structural mechanical refactor can be applied only when measured coverage evidence unlocks it.".to_string()
2147                    } else {
2148                        "Patchable Tier 1 mechanical hardening can be applied through the existing isolated validation transaction.".to_string()
2149                    }
2150                } else {
2151                    "Higher-evidence review candidate surfaced from security or boundary analysis; it remains plan-only until a safe executable recipe exists.".to_string()
2152                },
2153                file: file.clone(),
2154                line: finding.line,
2155                risk: risk_for_hardening_strategy(&finding.strategy),
2156                status: if evidence_satisfied {
2157                    if finding.patchable {
2158                        RefactorCandidateStatus::ApplyViaImprove
2159                    } else {
2160                        RefactorCandidateStatus::PlanOnly
2161                    }
2162                } else {
2163                    RefactorCandidateStatus::PlanOnly
2164                },
2165                tier: if required_evidence >= EvidenceGrade::Hardened {
2166                    RecipeTier::Tier3
2167                } else if required_evidence >= EvidenceGrade::Covered {
2168                    RecipeTier::Tier2
2169                } else if finding.patchable {
2170                    RecipeTier::Tier1
2171                } else {
2172                    RecipeTier::Tier2
2173                },
2174                required_evidence,
2175                evidence_satisfied,
2176                evidence_context: candidate_evidence_context(&file, evidence, measured),
2177                autonomy: CandidateAutonomyDecision::default(),
2178                public_api_impact: false,
2179                apply_command: (finding.patchable && evidence_satisfied)
2180                    .then(|| apply_command(&file, config, required_evidence)),
2181                required_gates: if finding.patchable {
2182                    required_gates(config.behavior_spec_path.is_some())
2183                } else {
2184                    vec![
2185                        "human review of boundary contract".to_string(),
2186                        "behavior evals or tests must cover the boundary".to_string(),
2187                        "future executable recipe must route through hardening transactions"
2188                            .to_string(),
2189                    ]
2190                },
2191            })
2192        })
2193        .collect()
2194}
2195
2196fn required_evidence_for_hardening_strategy(
2197    strategy: &mdx_rust_analysis::HardeningStrategy,
2198) -> EvidenceGrade {
2199    match strategy {
2200        mdx_rust_analysis::HardeningStrategy::LenCheckIsEmpty
2201        | mdx_rust_analysis::HardeningStrategy::OptionContextPropagation
2202        | mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => {
2203            EvidenceGrade::Covered
2204        }
2205        mdx_rust_analysis::HardeningStrategy::ClonePressureReview
2206        | mdx_rust_analysis::HardeningStrategy::LongFunctionReview => EvidenceGrade::Hardened,
2207        mdx_rust_analysis::HardeningStrategy::EnvAccessReview
2208        | mdx_rust_analysis::HardeningStrategy::FileIoReview
2209        | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview
2210        | mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
2211        | mdx_rust_analysis::HardeningStrategy::UnsafeReview => EvidenceGrade::Tested,
2212        _ => EvidenceGrade::Compiled,
2213    }
2214}
2215
2216fn recipe_for_hardening_strategy(
2217    strategy: &mdx_rust_analysis::HardeningStrategy,
2218) -> RefactorRecipe {
2219    match strategy {
2220        mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening => {
2221            RefactorRecipe::BorrowParameterTightening
2222        }
2223        mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation => {
2224            RefactorRecipe::ErrorContextPropagation
2225        }
2226        mdx_rust_analysis::HardeningStrategy::IteratorCloned => RefactorRecipe::IteratorCloned,
2227        mdx_rust_analysis::HardeningStrategy::LenCheckIsEmpty => RefactorRecipe::LenCheckIsEmpty,
2228        mdx_rust_analysis::HardeningStrategy::OptionContextPropagation => {
2229            RefactorRecipe::OptionContextPropagation
2230        }
2231        mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn => {
2232            RefactorRecipe::MustUsePublicReturn
2233        }
2234        mdx_rust_analysis::HardeningStrategy::ClonePressureReview => {
2235            RefactorRecipe::ClonePressureReview
2236        }
2237        mdx_rust_analysis::HardeningStrategy::LongFunctionReview => {
2238            RefactorRecipe::LongFunctionReview
2239        }
2240        mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => {
2241            RefactorRecipe::RepeatedStringLiteralConst
2242        }
2243        mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => {
2244            RefactorRecipe::BoundaryValidationReview
2245        }
2246        mdx_rust_analysis::HardeningStrategy::EnvAccessReview
2247        | mdx_rust_analysis::HardeningStrategy::FileIoReview => {
2248            RefactorRecipe::BoundaryValidationReview
2249        }
2250        mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
2251        | mdx_rust_analysis::HardeningStrategy::UnsafeReview => {
2252            RefactorRecipe::SecurityBoundaryReview
2253        }
2254        _ => RefactorRecipe::ContextualErrorHardening,
2255    }
2256}
2257
2258fn risk_for_hardening_strategy(
2259    strategy: &mdx_rust_analysis::HardeningStrategy,
2260) -> RefactorRiskLevel {
2261    match strategy {
2262        mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
2263        | mdx_rust_analysis::HardeningStrategy::UnsafeReview => RefactorRiskLevel::High,
2264        mdx_rust_analysis::HardeningStrategy::EnvAccessReview
2265        | mdx_rust_analysis::HardeningStrategy::FileIoReview
2266        | mdx_rust_analysis::HardeningStrategy::ClonePressureReview
2267        | mdx_rust_analysis::HardeningStrategy::LongFunctionReview
2268        | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => RefactorRiskLevel::Medium,
2269        _ => RefactorRiskLevel::Low,
2270    }
2271}
2272
2273fn structural_candidates(
2274    files: &[RefactorFileSummary],
2275    evidence: &EvidenceSummary,
2276    measured: Option<&EvidenceRun>,
2277) -> Vec<RefactorCandidate> {
2278    let mut candidates = Vec::new();
2279    let split_threshold = if evidence.grade >= EvidenceGrade::Hardened {
2280        220
2281    } else {
2282        300
2283    };
2284    let extract_threshold = if evidence.grade >= EvidenceGrade::Hardened {
2285        50
2286    } else {
2287        80
2288    };
2289    for file in files {
2290        let file_path = file.file.display().to_string();
2291        if file.line_count >= split_threshold {
2292            let required_evidence = EvidenceGrade::Covered;
2293            candidates.push(RefactorCandidate {
2294                id: format!("plan-split-module-{}", sanitize_id(&file_path)),
2295                candidate_hash: String::new(),
2296                recipe: RefactorRecipe::SplitModuleCandidate,
2297                title: "Split oversized module".to_string(),
2298                rationale: format!(
2299                    "{} has {} lines. Current evidence threshold is {split_threshold} lines for split-module planning.",
2300                    file_path, file.line_count
2301                ),
2302                file: file_path.clone(),
2303                line: 1,
2304                risk: if file.public_item_count > 0 {
2305                    RefactorRiskLevel::High
2306                } else {
2307                    RefactorRiskLevel::Medium
2308                },
2309                status: RefactorCandidateStatus::NeedsHumanDesign,
2310                tier: RecipeTier::Tier2,
2311                required_evidence,
2312                evidence_satisfied: evidence.grade >= required_evidence,
2313                evidence_context: candidate_evidence_context(&file_path, evidence, measured),
2314                autonomy: CandidateAutonomyDecision::default(),
2315                public_api_impact: file.public_item_count > 0,
2316                apply_command: None,
2317                required_gates: vec![
2318                    "human design review".to_string(),
2319                    "cargo check".to_string(),
2320                    "cargo clippy -- -D warnings".to_string(),
2321                    "behavior evals when configured".to_string(),
2322                ],
2323            });
2324        }
2325
2326        if file.largest_function_lines >= extract_threshold {
2327            let required_evidence = EvidenceGrade::Covered;
2328            candidates.push(RefactorCandidate {
2329                id: format!("plan-extract-function-{}", sanitize_id(&file_path)),
2330                candidate_hash: String::new(),
2331                recipe: RefactorRecipe::ExtractFunctionCandidate,
2332                title: "Extract long function".to_string(),
2333                rationale: format!(
2334                    "Largest function in {} is {} lines. Current evidence threshold is {extract_threshold} lines for extract-function planning.",
2335                    file_path, file.largest_function_lines
2336                ),
2337                file: file_path.clone(),
2338                line: 1,
2339                risk: RefactorRiskLevel::Medium,
2340                status: RefactorCandidateStatus::PlanOnly,
2341                tier: RecipeTier::Tier2,
2342                required_evidence,
2343                evidence_satisfied: evidence.grade >= required_evidence,
2344                evidence_context: candidate_evidence_context(&file_path, evidence, measured),
2345                autonomy: CandidateAutonomyDecision::default(),
2346                public_api_impact: file.public_item_count > 0,
2347                apply_command: None,
2348                required_gates: vec![
2349                    "targeted tests or behavior evals".to_string(),
2350                    "cargo check".to_string(),
2351                    "cargo clippy -- -D warnings".to_string(),
2352                ],
2353            });
2354        }
2355
2356        if file.public_item_count > 0 {
2357            let required_evidence = EvidenceGrade::Tested;
2358            candidates.push(RefactorCandidate {
2359                id: format!("plan-public-api-{}", sanitize_id(&file_path)),
2360                candidate_hash: String::new(),
2361                recipe: RefactorRecipe::PublicApiReview,
2362                title: "Protect public API before refactoring".to_string(),
2363                rationale: format!(
2364                    "{} exposes {} public item(s). Treat signature changes as semver-impacting.",
2365                    file_path, file.public_item_count
2366                ),
2367                file: file_path.clone(),
2368                line: 1,
2369                risk: RefactorRiskLevel::Medium,
2370                status: RefactorCandidateStatus::PlanOnly,
2371                tier: RecipeTier::Tier1,
2372                required_evidence,
2373                evidence_satisfied: evidence.grade >= required_evidence,
2374                evidence_context: candidate_evidence_context(&file_path, evidence, measured),
2375                autonomy: CandidateAutonomyDecision::default(),
2376                public_api_impact: true,
2377                apply_command: None,
2378                required_gates: vec![
2379                    "public API review".to_string(),
2380                    "docs and changelog review for exported changes".to_string(),
2381                ],
2382            });
2383        }
2384    }
2385
2386    candidates
2387}
2388
2389fn security_candidates(
2390    findings: &[AuditFinding],
2391    evidence: &EvidenceSummary,
2392    measured: Option<&EvidenceRun>,
2393) -> Vec<RefactorCandidate> {
2394    findings
2395        .iter()
2396        .filter(|finding| finding.severity != AuditSeverity::Info)
2397        .map(|finding| {
2398            let file = finding
2399                .file
2400                .clone()
2401                .unwrap_or_else(|| "<workspace>".to_string());
2402            let line = finding.line.unwrap_or(1);
2403            let required_evidence = match finding.severity {
2404                AuditSeverity::High => EvidenceGrade::Tested,
2405                AuditSeverity::Medium => EvidenceGrade::Tested,
2406                AuditSeverity::Low | AuditSeverity::Info => EvidenceGrade::Compiled,
2407            };
2408            let risk = match finding.severity {
2409                AuditSeverity::High => RefactorRiskLevel::High,
2410                AuditSeverity::Medium => RefactorRiskLevel::Medium,
2411                AuditSeverity::Low | AuditSeverity::Info => RefactorRiskLevel::Low,
2412            };
2413            RefactorCandidate {
2414                id: format!(
2415                    "plan-security-{}-{}-{}",
2416                    sanitize_id(&file),
2417                    sanitize_id(&finding.id),
2418                    line
2419                ),
2420                candidate_hash: String::new(),
2421                recipe: RefactorRecipe::SecurityBoundaryReview,
2422                title: finding.title.clone(),
2423                rationale: format!(
2424                    "Security audit flagged {:?}: {}",
2425                    finding.severity, finding.description
2426                ),
2427                file: file.clone(),
2428                line,
2429                risk,
2430                status: RefactorCandidateStatus::PlanOnly,
2431                tier: RecipeTier::Tier2,
2432                required_evidence,
2433                evidence_satisfied: evidence.grade >= required_evidence,
2434                evidence_context: candidate_evidence_context(&file, evidence, measured),
2435                autonomy: CandidateAutonomyDecision::default(),
2436                public_api_impact: false,
2437                apply_command: None,
2438                required_gates: vec![
2439                    "human security review".to_string(),
2440                    "policy update or explicit risk acceptance".to_string(),
2441                    "behavior evals or tests must cover the boundary".to_string(),
2442                ],
2443            }
2444        })
2445        .collect()
2446}
2447
2448fn candidate_evidence_context(
2449    file: &str,
2450    evidence: &EvidenceSummary,
2451    measured: Option<&EvidenceRun>,
2452) -> CandidateEvidenceContext {
2453    if let Some(profile) = measured
2454        .iter()
2455        .flat_map(|run| run.file_profiles.iter())
2456        .find(|profile| profile.file == file)
2457    {
2458        return CandidateEvidenceContext {
2459            grade: profile.grade,
2460            status: candidate_evidence_status(profile.grade),
2461            source: "measured file evidence profile".to_string(),
2462            profiled_file: Some(profile.file.clone()),
2463            signals: profile.signals.clone(),
2464        };
2465    }
2466    CandidateEvidenceContext {
2467        grade: evidence.grade,
2468        status: candidate_evidence_status(evidence.grade),
2469        source: if measured.is_some() {
2470            "measured run did not include this file; using run-level evidence".to_string()
2471        } else {
2472            "inferred evidence summary".to_string()
2473        },
2474        profiled_file: None,
2475        signals: evidence
2476            .signals
2477            .iter()
2478            .filter(|signal| signal.present)
2479            .map(|signal| signal.label.clone())
2480            .collect(),
2481    }
2482}
2483
2484fn candidate_evidence_status(grade: EvidenceGrade) -> CandidateEvidenceStatus {
2485    match grade {
2486        EvidenceGrade::None => CandidateEvidenceStatus::Unmeasured,
2487        EvidenceGrade::Compiled => CandidateEvidenceStatus::Compiled,
2488        EvidenceGrade::Tested => CandidateEvidenceStatus::Tested,
2489        EvidenceGrade::Covered => CandidateEvidenceStatus::Covered,
2490        EvidenceGrade::Hardened => CandidateEvidenceStatus::MutationBacked,
2491        EvidenceGrade::Proven => CandidateEvidenceStatus::Proven,
2492    }
2493}
2494
2495fn required_gates(has_behavior_spec: bool) -> Vec<String> {
2496    let mut gates = vec![
2497        "cargo check".to_string(),
2498        "cargo clippy -- -D warnings".to_string(),
2499        "review plan artifact before applying".to_string(),
2500    ];
2501    if has_behavior_spec {
2502        gates.push("behavior eval spec must pass in isolation and after apply".to_string());
2503    }
2504    gates
2505}
2506
2507fn apply_command(file: &str, config: &RefactorPlanConfig, evidence: EvidenceGrade) -> String {
2508    let mut command = format!("mdx-rust improve {} --apply", shell_word_str(file));
2509    if evidence >= EvidenceGrade::Covered {
2510        command.push_str(" --tier 2");
2511    }
2512    if let Some(policy) = &config.policy_path {
2513        command.push_str(&format!(" --policy {}", shell_word_path(policy)));
2514    }
2515    if let Some(eval_spec) = &config.behavior_spec_path {
2516        command.push_str(&format!(" --eval-spec {}", shell_word_path(eval_spec)));
2517    }
2518    command
2519}
2520
2521fn shell_word_path(path: &Path) -> String {
2522    shell_word_str(&path.display().to_string())
2523}
2524
2525fn shell_word_str(value: &str) -> String {
2526    if value
2527        .chars()
2528        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
2529    {
2530        value.to_string()
2531    } else {
2532        format!("'{}'", value.replace('\'', "'\\''"))
2533    }
2534}
2535
2536fn plan_id(
2537    root: &Path,
2538    config: &RefactorPlanConfig,
2539    impact: &RefactorImpactSummary,
2540    candidates: &[RefactorCandidate],
2541) -> String {
2542    let mut bytes = Vec::new();
2543    bytes.extend_from_slice(root.display().to_string().as_bytes());
2544    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
2545    bytes.extend_from_slice(format!("{:?}", config.policy_path).as_bytes());
2546    bytes.extend_from_slice(format!("{:?}", config.behavior_spec_path).as_bytes());
2547    bytes.extend_from_slice(format!("{impact:?}").as_bytes());
2548    bytes.extend_from_slice(format!("{candidates:?}").as_bytes());
2549    stable_hash_hex(&bytes)
2550}
2551
2552fn codebase_map_id(
2553    root: &Path,
2554    config: &CodebaseMapConfig,
2555    quality: &CodebaseQualitySummary,
2556    impact: &RefactorImpactSummary,
2557) -> String {
2558    let mut bytes = Vec::new();
2559    bytes.extend_from_slice(root.display().to_string().as_bytes());
2560    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
2561    bytes.extend_from_slice(format!("{quality:?}").as_bytes());
2562    bytes.extend_from_slice(format!("{impact:?}").as_bytes());
2563    stable_hash_hex(&bytes)
2564}
2565
2566fn codebase_map_hash(map: &CodebaseMap) -> String {
2567    let mut bytes = Vec::new();
2568    bytes.extend_from_slice(map.schema_version.as_bytes());
2569    bytes.extend_from_slice(map.map_id.as_bytes());
2570    bytes.extend_from_slice(map.root.as_bytes());
2571    bytes.extend_from_slice(format!("{:?}", map.target).as_bytes());
2572    bytes.extend_from_slice(format!("{:?}", map.quality).as_bytes());
2573    bytes.extend_from_slice(format!("{:?}", map.security).as_bytes());
2574    bytes.extend_from_slice(format!("{:?}", map.evidence).as_bytes());
2575    bytes.extend_from_slice(format!("{:?}", map.measured_evidence).as_bytes());
2576    bytes.extend_from_slice(format!("{:?}", map.impact).as_bytes());
2577    bytes.extend_from_slice(format!("{:?}", map.files).as_bytes());
2578    bytes.extend_from_slice(format!("{:?}", map.module_edges).as_bytes());
2579    bytes.extend_from_slice(format!("{:?}", map.findings).as_bytes());
2580    stable_hash_hex(&bytes)
2581}
2582
2583fn autopilot_run_id(root: &Path, config: &AutopilotConfig, map: &CodebaseMap) -> String {
2584    let mut bytes = Vec::new();
2585    bytes.extend_from_slice(root.display().to_string().as_bytes());
2586    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
2587    bytes.extend_from_slice(config.apply.to_string().as_bytes());
2588    bytes.extend_from_slice(config.max_passes.to_string().as_bytes());
2589    bytes.extend_from_slice(config.max_candidates.to_string().as_bytes());
2590    bytes.extend_from_slice(format!("{:?}", config.max_tier).as_bytes());
2591    bytes.extend_from_slice(format!("{:?}", config.min_evidence).as_bytes());
2592    bytes.extend_from_slice(map.map_hash.as_bytes());
2593    stable_hash_hex(&bytes)
2594}
2595
2596fn evolution_scorecard_id(
2597    root: &Path,
2598    config: &EvolutionScorecardConfig,
2599    map: &CodebaseMap,
2600    plan: &RefactorPlan,
2601) -> String {
2602    let mut bytes = Vec::new();
2603    bytes.extend_from_slice(root.display().to_string().as_bytes());
2604    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
2605    bytes.extend_from_slice(map.map_hash.as_bytes());
2606    bytes.extend_from_slice(plan.plan_hash.as_bytes());
2607    stable_hash_hex(&bytes)
2608}
2609
2610fn scorecard_next_commands(readiness: &AutonomyReadiness, plan: &RefactorPlan) -> Vec<String> {
2611    let target = plan.target.as_deref().unwrap_or("<target>");
2612    let target_arg = if target == "<target>" {
2613        target.to_string()
2614    } else {
2615        shell_quote_argument(target)
2616    };
2617    let mut commands = vec![
2618        format!("mdx-rust --json evidence {target_arg}"),
2619        format!("mdx-rust --json map {target_arg}"),
2620        format!("mdx-rust --json plan {target_arg}"),
2621    ];
2622    match readiness.grade {
2623        AutonomyReadinessGrade::Tier2Ready => commands.push(format!(
2624            "mdx-rust --json evolve {target_arg} --budget 10m --tier 2 --min-evidence covered"
2625        )),
2626        AutonomyReadinessGrade::Tier1Ready => commands.push(format!(
2627            "mdx-rust --json evolve {target_arg} --budget 10m --tier 1"
2628        )),
2629        AutonomyReadinessGrade::Tier3Planning => {
2630            commands.push(format!("mdx-rust --json plan {target_arg} --max-files 250"))
2631        }
2632        AutonomyReadinessGrade::ReviewOnly | AutonomyReadinessGrade::Blocked => {
2633            commands.push("mdx-rust --json audit".to_string())
2634        }
2635    }
2636    if let Some(path) = &plan.artifact_path {
2637        commands.push(format!(
2638            "mdx-rust --json explain {}",
2639            shell_quote_argument(path)
2640        ));
2641    }
2642    commands
2643}
2644
2645fn shell_quote_argument(value: &str) -> String {
2646    if value
2647        .bytes()
2648        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'-' | b'_'))
2649    {
2650        return value.to_string();
2651    }
2652
2653    format!("'{}'", value.replace('\'', "'\\''"))
2654}
2655
2656fn audit_scope_path(root: &Path, target: Option<&Path>) -> PathBuf {
2657    let Some(target) = target else {
2658        return root.to_path_buf();
2659    };
2660    if target.is_absolute() {
2661        target.to_path_buf()
2662    } else {
2663        root.join(target)
2664    }
2665}
2666
2667fn refactor_plan_hash(plan: &RefactorPlan) -> String {
2668    let mut bytes = Vec::new();
2669    bytes.extend_from_slice(plan.schema_version.as_bytes());
2670    bytes.extend_from_slice(plan.plan_id.as_bytes());
2671    bytes.extend_from_slice(plan.root.as_bytes());
2672    bytes.extend_from_slice(format!("{:?}", plan.target).as_bytes());
2673    bytes.extend_from_slice(format!("{:?}", plan.evidence).as_bytes());
2674    bytes.extend_from_slice(format!("{:?}", plan.measured_evidence).as_bytes());
2675    bytes.extend_from_slice(format!("{:?}", plan.security).as_bytes());
2676    bytes.extend_from_slice(format!("{:?}", plan.impact).as_bytes());
2677    bytes.extend_from_slice(format!("{:?}", plan.source_snapshots).as_bytes());
2678    bytes.extend_from_slice(format!("{:?}", plan.module_edges).as_bytes());
2679    bytes.extend_from_slice(format!("{:?}", plan.candidates).as_bytes());
2680    stable_hash_hex(&bytes)
2681}
2682
2683fn candidate_hash(candidate: &RefactorCandidate) -> String {
2684    let mut bytes = Vec::new();
2685    bytes.extend_from_slice(candidate.id.as_bytes());
2686    bytes.extend_from_slice(format!("{:?}", candidate.recipe).as_bytes());
2687    bytes.extend_from_slice(candidate.title.as_bytes());
2688    bytes.extend_from_slice(candidate.rationale.as_bytes());
2689    bytes.extend_from_slice(candidate.file.as_bytes());
2690    bytes.extend_from_slice(candidate.line.to_string().as_bytes());
2691    bytes.extend_from_slice(format!("{:?}", candidate.risk).as_bytes());
2692    bytes.extend_from_slice(format!("{:?}", candidate.status).as_bytes());
2693    bytes.extend_from_slice(format!("{:?}", candidate.tier).as_bytes());
2694    bytes.extend_from_slice(format!("{:?}", candidate.required_evidence).as_bytes());
2695    bytes.extend_from_slice(candidate.evidence_satisfied.to_string().as_bytes());
2696    bytes.extend_from_slice(format!("{:?}", candidate.evidence_context).as_bytes());
2697    bytes.extend_from_slice(format!("{:?}", candidate.autonomy).as_bytes());
2698    bytes.extend_from_slice(candidate.public_api_impact.to_string().as_bytes());
2699    bytes.extend_from_slice(format!("{:?}", candidate.apply_command).as_bytes());
2700    stable_hash_hex(&bytes)
2701}
2702
2703fn source_snapshots(
2704    root: &Path,
2705    files: &[RefactorFileSummary],
2706) -> anyhow::Result<Vec<SourceSnapshot>> {
2707    let mut snapshots = Vec::new();
2708    for file in files {
2709        let content = std::fs::read(root.join(&file.file))?;
2710        snapshots.push(SourceSnapshot {
2711            file: file.file.display().to_string(),
2712            hash: stable_hash_hex(&content),
2713        });
2714    }
2715    Ok(snapshots)
2716}
2717
2718fn stale_source_files(
2719    root: &Path,
2720    snapshots: &[SourceSnapshot],
2721) -> anyhow::Result<Vec<StaleSourceFile>> {
2722    let mut stale = Vec::new();
2723    for snapshot in snapshots {
2724        let rel = safe_relative_path(&snapshot.file)?;
2725        let actual_hash = std::fs::read(root.join(&rel))
2726            .map(|content| stable_hash_hex(&content))
2727            .unwrap_or_else(|_| "<missing>".to_string());
2728        if actual_hash != snapshot.hash {
2729            stale.push(StaleSourceFile {
2730                file: snapshot.file.clone(),
2731                expected_hash: snapshot.hash.clone(),
2732                actual_hash,
2733            });
2734        }
2735    }
2736    Ok(stale)
2737}
2738
2739fn stale_file_for_candidate(
2740    root: &Path,
2741    plan: &RefactorPlan,
2742    file: &str,
2743) -> anyhow::Result<Option<StaleSourceFile>> {
2744    let Some(snapshot) = plan
2745        .source_snapshots
2746        .iter()
2747        .find(|snapshot| snapshot.file == file)
2748    else {
2749        return Ok(Some(StaleSourceFile {
2750            file: file.to_string(),
2751            expected_hash: "<missing-snapshot>".to_string(),
2752            actual_hash: "<unknown>".to_string(),
2753        }));
2754    };
2755    let rel = safe_relative_path(&snapshot.file)?;
2756    let actual_hash = std::fs::read(root.join(&rel))
2757        .map(|content| stable_hash_hex(&content))
2758        .unwrap_or_else(|_| "<missing>".to_string());
2759    if actual_hash == snapshot.hash {
2760        Ok(None)
2761    } else {
2762        Ok(Some(StaleSourceFile {
2763            file: snapshot.file.clone(),
2764            expected_hash: snapshot.hash.clone(),
2765            actual_hash,
2766        }))
2767    }
2768}
2769
2770fn executable_candidate_queue<'a>(
2771    plan: &'a RefactorPlan,
2772    config: &RefactorBatchApplyConfig,
2773) -> Vec<&'a RefactorCandidate> {
2774    let mut queue = Vec::new();
2775    let mut seen_files = std::collections::BTreeSet::new();
2776    for candidate in &plan.candidates {
2777        if queue.len() >= config.max_candidates {
2778            break;
2779        }
2780        if candidate.status != RefactorCandidateStatus::ApplyViaImprove
2781            || !is_supported_mechanical_recipe(&candidate.recipe)
2782        {
2783            continue;
2784        }
2785        if !candidate.evidence_satisfied
2786            || candidate.required_evidence > plan.evidence.grade
2787            || plan.evidence.grade < config.min_evidence
2788            || candidate.tier > config.max_tier
2789            || candidate.autonomy.decision != AutonomyDecision::Allowed
2790        {
2791            continue;
2792        }
2793        if candidate.public_api_impact && !config.allow_public_api_impact {
2794            continue;
2795        }
2796        if seen_files.insert(candidate.file.clone()) {
2797            queue.push(candidate);
2798        }
2799    }
2800    queue
2801}
2802
2803fn is_supported_mechanical_recipe(recipe: &RefactorRecipe) -> bool {
2804    matches!(
2805        recipe,
2806        RefactorRecipe::BorrowParameterTightening
2807            | RefactorRecipe::ContextualErrorHardening
2808            | RefactorRecipe::ErrorContextPropagation
2809            | RefactorRecipe::IteratorCloned
2810            | RefactorRecipe::LenCheckIsEmpty
2811            | RefactorRecipe::MustUsePublicReturn
2812            | RefactorRecipe::OptionContextPropagation
2813            | RefactorRecipe::RepeatedStringLiteralConst
2814    )
2815}
2816
2817fn count_executable_candidates(
2818    plan: &RefactorPlan,
2819    allow_public_api_impact: bool,
2820    max_candidates: usize,
2821    max_tier: RecipeTier,
2822    min_evidence: EvidenceGrade,
2823) -> usize {
2824    executable_candidate_queue(
2825        plan,
2826        &RefactorBatchApplyConfig {
2827            plan_path: PathBuf::new(),
2828            apply: false,
2829            allow_public_api_impact,
2830            validation_timeout: Duration::from_secs(1),
2831            max_candidates,
2832            max_tier,
2833            min_evidence,
2834        },
2835    )
2836    .len()
2837}
2838
2839fn recipe_tier_number(tier: RecipeTier) -> u8 {
2840    match tier {
2841        RecipeTier::Tier1 => 1,
2842        RecipeTier::Tier2 => 2,
2843        RecipeTier::Tier3 => 3,
2844    }
2845}
2846
2847fn autopilot_pass_status(status: &RefactorBatchApplyStatus) -> AutopilotPassStatus {
2848    match status {
2849        RefactorBatchApplyStatus::Reviewed => AutopilotPassStatus::Reviewed,
2850        RefactorBatchApplyStatus::Applied => AutopilotPassStatus::Applied,
2851        RefactorBatchApplyStatus::PartiallyApplied => AutopilotPassStatus::PartiallyApplied,
2852        RefactorBatchApplyStatus::NoExecutableCandidates => {
2853            AutopilotPassStatus::NoExecutableCandidates
2854        }
2855        RefactorBatchApplyStatus::Rejected | RefactorBatchApplyStatus::StalePlan => {
2856            AutopilotPassStatus::Rejected
2857        }
2858    }
2859}
2860
2861fn autopilot_status(
2862    apply: bool,
2863    passes: &[AutopilotPass],
2864    executed_candidates: usize,
2865) -> AutopilotStatus {
2866    if executed_candidates == 0 {
2867        if passes
2868            .iter()
2869            .any(|pass| pass.status == AutopilotPassStatus::Rejected)
2870        {
2871            AutopilotStatus::Rejected
2872        } else {
2873            AutopilotStatus::NoExecutableCandidates
2874        }
2875    } else if !apply {
2876        AutopilotStatus::Reviewed
2877    } else if passes
2878        .iter()
2879        .any(|pass| pass.status == AutopilotPassStatus::Rejected)
2880    {
2881        AutopilotStatus::PartiallyApplied
2882    } else {
2883        AutopilotStatus::Applied
2884    }
2885}
2886
2887fn autopilot_note(run: &AutopilotRun) -> String {
2888    match run.status {
2889        AutopilotStatus::Reviewed => format!(
2890            "reviewed {} candidate(s) across {} pass(es); rerun with --apply to land validated transactions",
2891            run.total_executed_candidates,
2892            run.passes.len()
2893        ),
2894        AutopilotStatus::Applied => format!(
2895            "applied {} candidate(s) across {} pass(es) with fresh plans before each pass",
2896            run.total_executed_candidates,
2897            run.passes.len()
2898        ),
2899        AutopilotStatus::PartiallyApplied => format!(
2900            "applied {} candidate(s) before an execution gate stopped the run",
2901            run.total_executed_candidates
2902        ),
2903        AutopilotStatus::NoExecutableCandidates => {
2904            if run.budget_exhausted {
2905                "budget exhausted before more executable work could run".to_string()
2906            } else {
2907                "no executable low-risk candidates were available".to_string()
2908            }
2909        }
2910        AutopilotStatus::Rejected => {
2911            "autopilot stopped because a planning or execution gate rejected the run".to_string()
2912        }
2913    }
2914}
2915
2916fn autopilot_execution_summary(run: &AutopilotRun) -> AutopilotExecutionSummary {
2917    let plans_created = run.passes.len();
2918    let executable_candidates_seen = run
2919        .passes
2920        .iter()
2921        .map(|pass| pass.executable_candidates)
2922        .sum();
2923    let validated_transactions = run
2924        .passes
2925        .iter()
2926        .filter_map(|pass| pass.batch.as_ref())
2927        .flat_map(|batch| batch.steps.iter())
2928        .filter(|step| {
2929            step.hardening_run
2930                .as_ref()
2931                .is_some_and(|hardening| hardening.outcome.isolated_validation_passed)
2932        })
2933        .count();
2934    let applied_transactions = run
2935        .passes
2936        .iter()
2937        .filter_map(|pass| pass.batch.as_ref())
2938        .flat_map(|batch| batch.steps.iter())
2939        .filter(|step| {
2940            step.hardening_run
2941                .as_ref()
2942                .is_some_and(|hardening| hardening.outcome.applied)
2943        })
2944        .count();
2945    let blocked_or_plan_only_candidates = run
2946        .total_planned_candidates
2947        .saturating_sub(executable_candidates_seen);
2948
2949    AutopilotExecutionSummary {
2950        plans_created,
2951        executable_candidates_seen,
2952        validated_transactions,
2953        applied_transactions,
2954        blocked_or_plan_only_candidates,
2955        evidence_grade: run.evidence.grade,
2956        analysis_depth: run.evidence.analysis_depth.clone(),
2957    }
2958}
2959
2960fn batch_status(apply: bool, executed: usize, requested: usize) -> RefactorBatchApplyStatus {
2961    if requested == 0 {
2962        RefactorBatchApplyStatus::NoExecutableCandidates
2963    } else if executed == 0 {
2964        RefactorBatchApplyStatus::Rejected
2965    } else if !apply {
2966        RefactorBatchApplyStatus::Reviewed
2967    } else if executed == requested {
2968        RefactorBatchApplyStatus::Applied
2969    } else {
2970        RefactorBatchApplyStatus::PartiallyApplied
2971    }
2972}
2973
2974fn safe_relative_path(value: &str) -> anyhow::Result<PathBuf> {
2975    let path = PathBuf::from(value);
2976    if path.is_absolute()
2977        || path.components().any(|component| {
2978            matches!(
2979                component,
2980                Component::ParentDir | Component::RootDir | Component::Prefix(_)
2981            )
2982        })
2983    {
2984        anyhow::bail!("refactor plan contains unscoped path: {value}");
2985    }
2986    Ok(path)
2987}
2988
2989fn sanitize_id(value: &str) -> String {
2990    value
2991        .chars()
2992        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
2993        .collect::<String>()
2994        .trim_matches('-')
2995        .to_string()
2996}
2997
2998fn persist_refactor_plan(artifact_root: &Path, plan: &RefactorPlan) -> anyhow::Result<PathBuf> {
2999    let dir = artifact_root.join("plans");
3000    std::fs::create_dir_all(&dir)?;
3001    let millis = std::time::SystemTime::now()
3002        .duration_since(std::time::UNIX_EPOCH)
3003        .map(|duration| duration.as_millis())
3004        .unwrap_or(0);
3005    Ok(dir.join(format!("refactor-plan-{millis}-{}.json", plan.plan_id)))
3006}
3007
3008fn persist_apply_run(
3009    artifact_root: Option<&Path>,
3010    mut run: RefactorApplyRun,
3011) -> anyhow::Result<RefactorApplyRun> {
3012    if let Some(artifact_root) = artifact_root {
3013        let dir = artifact_root.join("plans");
3014        std::fs::create_dir_all(&dir)?;
3015        let millis = std::time::SystemTime::now()
3016            .duration_since(std::time::UNIX_EPOCH)
3017            .map(|duration| duration.as_millis())
3018            .unwrap_or(0);
3019        let path = dir.join(format!(
3020            "apply-plan-{millis}-{}-{}.json",
3021            sanitize_id(&run.plan_id),
3022            sanitize_id(&run.candidate_id)
3023        ));
3024        run.artifact_path = Some(path.display().to_string());
3025        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
3026    }
3027    Ok(run)
3028}
3029
3030fn persist_batch_apply_run(
3031    artifact_root: Option<&Path>,
3032    mut run: RefactorBatchApplyRun,
3033) -> anyhow::Result<RefactorBatchApplyRun> {
3034    if let Some(artifact_root) = artifact_root {
3035        let dir = artifact_root.join("plans");
3036        std::fs::create_dir_all(&dir)?;
3037        let millis = std::time::SystemTime::now()
3038            .duration_since(std::time::UNIX_EPOCH)
3039            .map(|duration| duration.as_millis())
3040            .unwrap_or(0);
3041        let path = dir.join(format!(
3042            "apply-plan-batch-{millis}-{}.json",
3043            sanitize_id(&run.plan_id)
3044        ));
3045        run.artifact_path = Some(path.display().to_string());
3046        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
3047    }
3048    Ok(run)
3049}
3050
3051fn persist_codebase_map(artifact_root: &Path, map: &CodebaseMap) -> anyhow::Result<PathBuf> {
3052    let dir = artifact_root.join("maps");
3053    std::fs::create_dir_all(&dir)?;
3054    let millis = std::time::SystemTime::now()
3055        .duration_since(std::time::UNIX_EPOCH)
3056        .map(|duration| duration.as_millis())
3057        .unwrap_or(0);
3058    Ok(dir.join(format!(
3059        "codebase-map-{millis}-{}.json",
3060        sanitize_id(&map.map_id)
3061    )))
3062}
3063
3064fn persist_autopilot_run(
3065    artifact_root: Option<&Path>,
3066    mut run: AutopilotRun,
3067) -> anyhow::Result<AutopilotRun> {
3068    if let Some(artifact_root) = artifact_root {
3069        let dir = artifact_root.join("autopilot");
3070        std::fs::create_dir_all(&dir)?;
3071        let millis = std::time::SystemTime::now()
3072            .duration_since(std::time::UNIX_EPOCH)
3073            .map(|duration| duration.as_millis())
3074            .unwrap_or(0);
3075        let path = dir.join(format!(
3076            "autopilot-{millis}-{}.json",
3077            sanitize_id(&run.run_id)
3078        ));
3079        run.artifact_path = Some(path.display().to_string());
3080        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
3081    }
3082    Ok(run)
3083}
3084
3085fn persist_evolution_scorecard(
3086    artifact_root: &Path,
3087    scorecard: &EvolutionScorecard,
3088) -> anyhow::Result<PathBuf> {
3089    let dir = artifact_root.join("scorecards");
3090    std::fs::create_dir_all(&dir)?;
3091    let millis = std::time::SystemTime::now()
3092        .duration_since(std::time::UNIX_EPOCH)
3093        .map(|duration| duration.as_millis())
3094        .unwrap_or(0);
3095    Ok(dir.join(format!(
3096        "evolution-scorecard-{millis}-{}.json",
3097        sanitize_id(&scorecard.scorecard_id)
3098    )))
3099}
3100
3101#[cfg(test)]
3102mod tests {
3103    use super::*;
3104    use tempfile::tempdir;
3105
3106    #[test]
3107    fn refactor_plan_points_patchable_changes_to_improve() {
3108        let dir = tempdir().unwrap();
3109        std::fs::write(
3110            dir.path().join("Cargo.toml"),
3111            r#"[package]
3112name = "plan-fixture"
3113version = "0.1.0"
3114edition = "2021"
3115
3116[dependencies]
3117anyhow = "1"
3118"#,
3119        )
3120        .unwrap();
3121        std::fs::create_dir_all(dir.path().join("src")).unwrap();
3122        std::fs::write(
3123            dir.path().join("src/lib.rs"),
3124            r#"pub fn load_config() -> anyhow::Result<String> {
3125    let content = std::fs::read_to_string("missing.toml").unwrap();
3126    Ok(content)
3127}
3128"#,
3129        )
3130        .unwrap();
3131
3132        let plan = build_refactor_plan(
3133            dir.path(),
3134            None,
3135            &RefactorPlanConfig {
3136                target: Some(PathBuf::from("src/lib.rs")),
3137                behavior_spec_path: Some(PathBuf::from(".mdx-rust/evals.json")),
3138                ..RefactorPlanConfig::default()
3139            },
3140        )
3141        .unwrap();
3142
3143        assert_eq!(plan.schema_version, "0.9");
3144        assert!(plan.candidates.iter().any(|candidate| candidate.status
3145            == RefactorCandidateStatus::ApplyViaImprove
3146            && candidate
3147                .apply_command
3148                .as_deref()
3149                .is_some_and(|command| command.contains("--eval-spec"))));
3150    }
3151
3152    #[test]
3153    fn tested_evidence_surfaces_boundary_review_candidates() {
3154        let dir = tempdir().unwrap();
3155        std::fs::write(
3156            dir.path().join("Cargo.toml"),
3157            r#"[package]
3158name = "tested-plan-fixture"
3159version = "0.1.0"
3160edition = "2021"
3161"#,
3162        )
3163        .unwrap();
3164        std::fs::create_dir_all(dir.path().join("src")).unwrap();
3165        std::fs::write(
3166            dir.path().join("src/lib.rs"),
3167            r#"pub fn shell(cmd: &str) {
3168    std::process::Command::new(cmd);
3169}
3170
3171#[cfg(test)]
3172mod tests {
3173    #[test]
3174    fn smoke() {
3175        assert_eq!(1, 1);
3176    }
3177}
3178"#,
3179        )
3180        .unwrap();
3181
3182        let plan = build_refactor_plan(
3183            dir.path(),
3184            None,
3185            &RefactorPlanConfig {
3186                target: Some(PathBuf::from("src/lib.rs")),
3187                ..RefactorPlanConfig::default()
3188            },
3189        )
3190        .unwrap();
3191
3192        assert_eq!(plan.evidence.grade, EvidenceGrade::Tested);
3193        assert_eq!(
3194            plan.evidence.analysis_depth,
3195            EvidenceAnalysisDepth::BoundaryAware
3196        );
3197        assert!(plan.candidates.iter().any(|candidate| candidate.status
3198            == RefactorCandidateStatus::PlanOnly
3199            && candidate.required_evidence == EvidenceGrade::Tested
3200            && candidate.tier == RecipeTier::Tier2));
3201    }
3202
3203    #[test]
3204    fn measured_covered_evidence_unlocks_tier2_executable_recipe() {
3205        let dir = tempdir().unwrap();
3206        std::fs::write(
3207            dir.path().join("Cargo.toml"),
3208            r#"[package]
3209name = "covered-plan-fixture"
3210version = "0.1.0"
3211edition = "2021"
3212"#,
3213        )
3214        .unwrap();
3215        std::fs::create_dir_all(dir.path().join("src")).unwrap();
3216        std::fs::write(
3217            dir.path().join("src/lib.rs"),
3218            r#"pub fn labels(items: &[String]) -> Vec<&'static str> {
3219    if items.len() == 0 {
3220        return vec!["shared boundary label"];
3221    }
3222    vec![
3223        "shared boundary label",
3224        "shared boundary label",
3225        "shared boundary label",
3226    ]
3227}
3228"#,
3229        )
3230        .unwrap();
3231        let artifact_root = dir.path().join(".mdx-rust");
3232        std::fs::create_dir_all(artifact_root.join("evidence")).unwrap();
3233        let evidence = crate::evidence::EvidenceRun {
3234            schema_version: "0.9".to_string(),
3235            run_id: "covered-fixture".to_string(),
3236            root: dir.path().canonicalize().unwrap().display().to_string(),
3237            target: Some("src/lib.rs".to_string()),
3238            grade: EvidenceGrade::Covered,
3239            analysis_depth: EvidenceAnalysisDepth::Structural,
3240            metrics: Vec::new(),
3241            file_profiles: Vec::new(),
3242            commands: Vec::new(),
3243            unlocked_recipe_tiers: vec!["Tier 2 structural mechanical recipes".to_string()],
3244            unlock_suggestions: Vec::new(),
3245            note: "fixture evidence".to_string(),
3246            artifact_path: Some(
3247                artifact_root
3248                    .join("evidence/evidence-fixture.json")
3249                    .display()
3250                    .to_string(),
3251            ),
3252        };
3253        std::fs::write(
3254            artifact_root.join("evidence/evidence-fixture.json"),
3255            serde_json::to_string_pretty(&evidence).unwrap(),
3256        )
3257        .unwrap();
3258
3259        let plan = build_refactor_plan(
3260            dir.path(),
3261            Some(&artifact_root),
3262            &RefactorPlanConfig {
3263                target: Some(PathBuf::from("src/lib.rs")),
3264                ..RefactorPlanConfig::default()
3265            },
3266        )
3267        .unwrap();
3268
3269        assert_eq!(plan.evidence.grade, EvidenceGrade::Covered);
3270        assert!(plan.measured_evidence.is_some());
3271        assert!(plan.candidates.iter().any(|candidate| candidate.recipe
3272            == RefactorRecipe::RepeatedStringLiteralConst
3273            && candidate.status == RefactorCandidateStatus::ApplyViaImprove
3274            && candidate.required_evidence == EvidenceGrade::Covered
3275            && candidate.tier == RecipeTier::Tier2
3276            && candidate
3277                .apply_command
3278                .as_deref()
3279                .is_some_and(|command| command.contains("--tier 2"))));
3280        assert!(plan.candidates.iter().any(|candidate| candidate.recipe
3281            == RefactorRecipe::LenCheckIsEmpty
3282            && candidate.status == RefactorCandidateStatus::ApplyViaImprove
3283            && candidate.required_evidence == EvidenceGrade::Covered
3284            && candidate.tier == RecipeTier::Tier2));
3285    }
3286}