Skip to main content

mdx_rust_core/
refactor.rs

1//! Plan-first guardrailed refactoring.
2//!
3//! v0.6 keeps auditable plans as the mutation boundary and adds autonomous
4//! orchestration over the safe executable subset.
5
6use crate::eval::stable_hash_hex;
7use crate::hardening::{
8    run_hardening, workspace_summary, HardeningConfig, HardeningRun, WorkspaceSummary,
9};
10use crate::policy::{load_project_policy, ProjectPolicy};
11use mdx_rust_analysis::{
12    analyze_hardening, analyze_refactor, HardeningAnalyzeConfig, HardeningFinding, ModuleEdge,
13    RefactorAnalyzeConfig, RefactorFileSummary,
14};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use std::path::{Component, Path, PathBuf};
18use std::time::Duration;
19
20#[derive(Debug, Clone)]
21pub struct RefactorPlanConfig {
22    pub target: Option<PathBuf>,
23    pub policy_path: Option<PathBuf>,
24    pub behavior_spec_path: Option<PathBuf>,
25    pub max_files: usize,
26}
27
28impl Default for RefactorPlanConfig {
29    fn default() -> Self {
30        Self {
31            target: None,
32            policy_path: None,
33            behavior_spec_path: None,
34            max_files: 100,
35        }
36    }
37}
38
39#[derive(Debug, Clone)]
40pub struct RefactorApplyConfig {
41    pub plan_path: PathBuf,
42    pub candidate_id: String,
43    pub apply: bool,
44    pub allow_public_api_impact: bool,
45    pub validation_timeout: Duration,
46}
47
48#[derive(Debug, Clone)]
49pub struct RefactorBatchApplyConfig {
50    pub plan_path: PathBuf,
51    pub apply: bool,
52    pub allow_public_api_impact: bool,
53    pub validation_timeout: Duration,
54    pub max_candidates: usize,
55    pub max_tier: RecipeTier,
56    pub min_evidence: EvidenceGrade,
57}
58
59#[derive(Debug, Clone)]
60pub struct CodebaseMapConfig {
61    pub target: Option<PathBuf>,
62    pub policy_path: Option<PathBuf>,
63    pub behavior_spec_path: Option<PathBuf>,
64    pub max_files: usize,
65}
66
67impl Default for CodebaseMapConfig {
68    fn default() -> Self {
69        Self {
70            target: None,
71            policy_path: None,
72            behavior_spec_path: None,
73            max_files: 250,
74        }
75    }
76}
77
78#[derive(Debug, Clone)]
79pub struct AutopilotConfig {
80    pub target: Option<PathBuf>,
81    pub policy_path: Option<PathBuf>,
82    pub behavior_spec_path: Option<PathBuf>,
83    pub apply: bool,
84    pub max_files: usize,
85    pub max_passes: usize,
86    pub max_candidates: usize,
87    pub validation_timeout: Duration,
88    pub allow_public_api_impact: bool,
89    pub max_tier: RecipeTier,
90    pub min_evidence: EvidenceGrade,
91    pub budget: Option<Duration>,
92}
93
94impl Default for AutopilotConfig {
95    fn default() -> Self {
96        Self {
97            target: None,
98            policy_path: None,
99            behavior_spec_path: None,
100            apply: false,
101            max_files: 250,
102            max_passes: 3,
103            max_candidates: 25,
104            validation_timeout: Duration::from_secs(180),
105            allow_public_api_impact: false,
106            max_tier: RecipeTier::Tier1,
107            min_evidence: EvidenceGrade::Compiled,
108            budget: None,
109        }
110    }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114pub struct RefactorPlan {
115    pub schema_version: String,
116    pub plan_id: String,
117    pub plan_hash: String,
118    pub root: String,
119    pub target: Option<String>,
120    pub workspace: WorkspaceSummary,
121    pub policy: Option<ProjectPolicy>,
122    pub behavior_spec: Option<String>,
123    pub evidence: EvidenceSummary,
124    pub impact: RefactorImpactSummary,
125    pub source_snapshots: Vec<SourceSnapshot>,
126    pub files: Vec<RefactorFileSummary>,
127    pub module_edges: Vec<ModuleEdge>,
128    pub candidates: Vec<RefactorCandidate>,
129    pub required_gates: Vec<String>,
130    pub non_goals: Vec<String>,
131    pub artifact_path: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
135pub struct CodebaseMap {
136    pub schema_version: String,
137    pub map_id: String,
138    pub map_hash: String,
139    pub root: String,
140    pub target: Option<String>,
141    pub workspace: WorkspaceSummary,
142    pub policy: Option<ProjectPolicy>,
143    pub behavior_spec: Option<String>,
144    pub evidence: EvidenceSummary,
145    pub quality: CodebaseQualitySummary,
146    pub capability_gates: Vec<CapabilityGate>,
147    pub impact: RefactorImpactSummary,
148    pub files: Vec<RefactorFileSummary>,
149    pub module_edges: Vec<ModuleEdge>,
150    pub findings: Vec<HardeningFinding>,
151    pub recommended_actions: Vec<String>,
152    pub artifact_path: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
156pub struct CodebaseQualitySummary {
157    pub grade: CodebaseQualityGrade,
158    pub debt_score: u8,
159    pub patchable_findings: usize,
160    pub review_only_findings: usize,
161    pub public_api_pressure: usize,
162    pub oversized_files: usize,
163    pub oversized_functions: usize,
164    pub test_coverage_signal: TestCoverageSignal,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
168pub struct EvidenceSummary {
169    pub grade: EvidenceGrade,
170    pub max_autonomous_tier: u8,
171    pub analysis_depth: EvidenceAnalysisDepth,
172    pub signals: Vec<EvidenceSignal>,
173    pub unlocked_recipe_tiers: Vec<String>,
174    pub unlock_suggestions: Vec<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
178pub enum EvidenceAnalysisDepth {
179    None,
180    Mechanical,
181    BoundaryAware,
182    Structural,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
186pub struct EvidenceSignal {
187    pub id: String,
188    pub label: String,
189    pub present: bool,
190    pub detail: String,
191}
192
193#[derive(
194    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
195)]
196pub enum EvidenceGrade {
197    None,
198    Compiled,
199    Tested,
200    Covered,
201    Hardened,
202    Proven,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
206pub enum CodebaseQualityGrade {
207    Excellent,
208    Good,
209    NeedsWork,
210    HighRisk,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
214pub enum TestCoverageSignal {
215    Present,
216    Sparse,
217    Unknown,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
221pub struct CapabilityGate {
222    pub id: String,
223    pub label: String,
224    pub available: bool,
225    pub command: String,
226    pub purpose: String,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
230pub struct AutopilotRun {
231    pub schema_version: String,
232    pub run_id: String,
233    pub root: String,
234    pub target: Option<String>,
235    pub mode: RefactorApplyMode,
236    pub status: AutopilotStatus,
237    pub budget_seconds: Option<u64>,
238    pub max_passes: usize,
239    pub max_candidates_per_pass: usize,
240    pub quality_before: CodebaseQualitySummary,
241    pub quality_after: Option<CodebaseQualitySummary>,
242    pub evidence: EvidenceSummary,
243    pub execution_summary: AutopilotExecutionSummary,
244    pub passes: Vec<AutopilotPass>,
245    pub total_planned_candidates: usize,
246    pub total_executed_candidates: usize,
247    pub total_skipped_candidates: usize,
248    pub budget_exhausted: bool,
249    pub note: String,
250    pub artifact_path: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
254pub struct AutopilotExecutionSummary {
255    pub plans_created: usize,
256    pub executable_candidates_seen: usize,
257    pub validated_transactions: usize,
258    pub applied_transactions: usize,
259    pub blocked_or_plan_only_candidates: usize,
260    pub evidence_grade: EvidenceGrade,
261    pub analysis_depth: EvidenceAnalysisDepth,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265pub struct AutopilotPass {
266    pub pass_index: usize,
267    pub plan_id: String,
268    pub plan_hash: String,
269    pub plan_artifact_path: Option<String>,
270    pub planned_candidates: usize,
271    pub executable_candidates: usize,
272    pub batch: Option<RefactorBatchApplyRun>,
273    pub status: AutopilotPassStatus,
274    pub note: String,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
278pub enum AutopilotStatus {
279    Reviewed,
280    Applied,
281    PartiallyApplied,
282    NoExecutableCandidates,
283    Rejected,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
287pub enum AutopilotPassStatus {
288    Planned,
289    Reviewed,
290    Applied,
291    PartiallyApplied,
292    NoExecutableCandidates,
293    Rejected,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297pub struct SourceSnapshot {
298    pub file: String,
299    pub hash: String,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
303pub struct RefactorImpactSummary {
304    pub files_scanned: usize,
305    pub public_item_count: usize,
306    pub public_files: usize,
307    pub module_edge_count: usize,
308    pub patchable_hardening_changes: usize,
309    pub review_only_findings: usize,
310    pub oversized_files: usize,
311    pub oversized_functions: usize,
312    pub risk_level: RefactorRiskLevel,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
316pub enum RefactorRiskLevel {
317    Low,
318    Medium,
319    High,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
323pub struct RefactorCandidate {
324    pub id: String,
325    pub candidate_hash: String,
326    pub recipe: RefactorRecipe,
327    pub title: String,
328    pub rationale: String,
329    pub file: String,
330    pub line: usize,
331    pub risk: RefactorRiskLevel,
332    pub status: RefactorCandidateStatus,
333    pub tier: RecipeTier,
334    pub required_evidence: EvidenceGrade,
335    pub evidence_satisfied: bool,
336    pub public_api_impact: bool,
337    pub apply_command: Option<String>,
338    pub required_gates: Vec<String>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
342pub enum RefactorCandidateStatus {
343    ApplyViaImprove,
344    PlanOnly,
345    NeedsHumanDesign,
346}
347
348#[derive(
349    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
350)]
351pub enum RecipeTier {
352    Tier1,
353    Tier2,
354    Tier3,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
358pub enum RefactorRecipe {
359    BorrowParameterTightening,
360    ContextualErrorHardening,
361    ErrorContextPropagation,
362    ExtractFunctionCandidate,
363    IteratorCloned,
364    MustUsePublicReturn,
365    SecurityBoundaryReview,
366    SplitModuleCandidate,
367    BoundaryValidationReview,
368    PublicApiReview,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372pub struct RefactorApplyRun {
373    pub schema_version: String,
374    pub root: String,
375    pub plan_path: String,
376    pub plan_id: String,
377    pub plan_hash: String,
378    pub candidate_id: String,
379    pub candidate_hash: Option<String>,
380    pub mode: RefactorApplyMode,
381    pub status: RefactorApplyStatus,
382    pub public_api_impact_allowed: bool,
383    pub stale_files: Vec<StaleSourceFile>,
384    pub hardening_run: Option<HardeningRun>,
385    pub note: String,
386    pub artifact_path: Option<String>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
390pub struct RefactorBatchApplyRun {
391    pub schema_version: String,
392    pub root: String,
393    pub plan_path: String,
394    pub plan_id: String,
395    pub plan_hash: String,
396    pub mode: RefactorApplyMode,
397    pub status: RefactorBatchApplyStatus,
398    pub public_api_impact_allowed: bool,
399    pub max_candidates: usize,
400    pub requested_candidates: usize,
401    pub executed_candidates: usize,
402    pub skipped_candidates: usize,
403    pub steps: Vec<RefactorBatchCandidateRun>,
404    pub note: String,
405    pub artifact_path: Option<String>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
409pub struct RefactorBatchCandidateRun {
410    pub candidate_id: String,
411    pub candidate_hash: Option<String>,
412    pub file: String,
413    pub status: RefactorApplyStatus,
414    pub stale_file: Option<StaleSourceFile>,
415    pub hardening_run: Option<HardeningRun>,
416    pub note: String,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
420pub enum RefactorApplyMode {
421    Review,
422    Apply,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
426pub enum RefactorBatchApplyStatus {
427    Reviewed,
428    Applied,
429    PartiallyApplied,
430    Rejected,
431    StalePlan,
432    NoExecutableCandidates,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
436pub enum RefactorApplyStatus {
437    Reviewed,
438    Applied,
439    Rejected,
440    StalePlan,
441    Unsupported,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
445pub struct StaleSourceFile {
446    pub file: String,
447    pub expected_hash: String,
448    pub actual_hash: String,
449}
450
451pub fn build_refactor_plan(
452    root: &Path,
453    artifact_root: Option<&Path>,
454    config: &RefactorPlanConfig,
455) -> anyhow::Result<RefactorPlan> {
456    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
457    let refactor = analyze_refactor(
458        &root,
459        RefactorAnalyzeConfig {
460            target: config.target.as_deref(),
461            max_files: config.max_files,
462        },
463    )?;
464    let hardening = analyze_hardening(
465        &root,
466        HardeningAnalyzeConfig {
467            target: config.target.as_deref(),
468            max_files: config.max_files,
469        },
470    )?;
471    let policy = load_project_policy(&root, config.policy_path.as_deref())?;
472    let workspace = workspace_summary(&root);
473    let behavior_spec = config
474        .behavior_spec_path
475        .as_ref()
476        .map(|path| path.display().to_string());
477    let capability_gates = capability_gates();
478    let evidence = summarize_evidence(
479        &workspace,
480        &refactor.files,
481        &capability_gates,
482        config.behavior_spec_path.is_some(),
483    );
484    let impact = summarize_impact(
485        &refactor.files,
486        refactor.module_edges.len(),
487        &hardening.findings,
488        hardening.changes.len(),
489    );
490    let mut candidates = Vec::new();
491    candidates.extend(hardening_candidates(&hardening.findings, config, &evidence));
492    candidates.extend(structural_candidates(&refactor.files, &evidence));
493    for candidate in &mut candidates {
494        candidate.candidate_hash = candidate_hash(candidate);
495    }
496    candidates.sort_by(|left, right| left.id.cmp(&right.id));
497    let source_snapshots = source_snapshots(&root, &refactor.files)?;
498
499    let required_gates = required_gates(config.behavior_spec_path.is_some());
500    let non_goals = vec![
501        "No broad API-changing refactors without explicit human allowance.".to_string(),
502        "No public API changes without explicit human review.".to_string(),
503        "No plan candidate may bypass improve/apply validation gates.".to_string(),
504    ];
505
506    let plan_id = plan_id(&root, config, &impact, &candidates);
507    let mut plan = RefactorPlan {
508        schema_version: "0.6".to_string(),
509        plan_id,
510        plan_hash: String::new(),
511        root: root.display().to_string(),
512        target: config
513            .target
514            .as_ref()
515            .map(|path| path.display().to_string()),
516        workspace,
517        policy,
518        behavior_spec,
519        evidence,
520        impact,
521        source_snapshots,
522        files: refactor.files,
523        module_edges: refactor.module_edges,
524        candidates,
525        required_gates,
526        non_goals,
527        artifact_path: None,
528    };
529    plan.plan_hash = refactor_plan_hash(&plan);
530
531    if let Some(artifact_root) = artifact_root {
532        let path = persist_refactor_plan(artifact_root, &plan)?;
533        plan.artifact_path = Some(path.display().to_string());
534        std::fs::write(&path, serde_json::to_string_pretty(&plan)?)?;
535    }
536
537    Ok(plan)
538}
539
540pub fn build_codebase_map(
541    root: &Path,
542    artifact_root: Option<&Path>,
543    config: &CodebaseMapConfig,
544) -> anyhow::Result<CodebaseMap> {
545    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
546    let refactor = analyze_refactor(
547        &root,
548        RefactorAnalyzeConfig {
549            target: config.target.as_deref(),
550            max_files: config.max_files,
551        },
552    )?;
553    let hardening = analyze_hardening(
554        &root,
555        HardeningAnalyzeConfig {
556            target: config.target.as_deref(),
557            max_files: config.max_files,
558        },
559    )?;
560    let policy = load_project_policy(&root, config.policy_path.as_deref())?;
561    let workspace = workspace_summary(&root);
562    let behavior_spec = config
563        .behavior_spec_path
564        .as_ref()
565        .map(|path| path.display().to_string());
566    let capability_gates = capability_gates();
567    let evidence = summarize_evidence(
568        &workspace,
569        &refactor.files,
570        &capability_gates,
571        config.behavior_spec_path.is_some(),
572    );
573    let impact = summarize_impact(
574        &refactor.files,
575        refactor.module_edges.len(),
576        &hardening.findings,
577        hardening.changes.len(),
578    );
579    let quality = summarize_quality(&refactor.files, &hardening.findings, &impact);
580    let recommended_actions = recommended_actions(&quality, &impact, &capability_gates, &evidence);
581    let map_id = codebase_map_id(&root, config, &quality, &impact);
582    let mut map = CodebaseMap {
583        schema_version: "0.6".to_string(),
584        map_id,
585        map_hash: String::new(),
586        root: root.display().to_string(),
587        target: config
588            .target
589            .as_ref()
590            .map(|path| path.display().to_string()),
591        workspace,
592        policy,
593        behavior_spec,
594        evidence,
595        quality,
596        capability_gates,
597        impact,
598        files: refactor.files,
599        module_edges: refactor.module_edges,
600        findings: hardening.findings,
601        recommended_actions,
602        artifact_path: None,
603    };
604    map.map_hash = codebase_map_hash(&map);
605
606    if let Some(artifact_root) = artifact_root {
607        let path = persist_codebase_map(artifact_root, &map)?;
608        map.artifact_path = Some(path.display().to_string());
609        std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
610    }
611
612    Ok(map)
613}
614
615pub fn run_autopilot(
616    root: &Path,
617    artifact_root: Option<&Path>,
618    config: &AutopilotConfig,
619) -> anyhow::Result<AutopilotRun> {
620    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
621    let map_config = CodebaseMapConfig {
622        target: config.target.clone(),
623        policy_path: config.policy_path.clone(),
624        behavior_spec_path: config.behavior_spec_path.clone(),
625        max_files: config.max_files,
626    };
627    let before_map = build_codebase_map(&root, artifact_root, &map_config)?;
628    let evidence = before_map.evidence.clone();
629    let quality_before = before_map.quality.clone();
630    let mode = if config.apply {
631        RefactorApplyMode::Apply
632    } else {
633        RefactorApplyMode::Review
634    };
635    let mut run = AutopilotRun {
636        schema_version: "0.6".to_string(),
637        run_id: autopilot_run_id(&root, config, &before_map),
638        root: root.display().to_string(),
639        target: config
640            .target
641            .as_ref()
642            .map(|path| path.display().to_string()),
643        mode,
644        status: AutopilotStatus::NoExecutableCandidates,
645        budget_seconds: config.budget.map(|duration| duration.as_secs()),
646        max_passes: config.max_passes,
647        max_candidates_per_pass: config.max_candidates,
648        quality_before,
649        quality_after: None,
650        evidence,
651        execution_summary: AutopilotExecutionSummary {
652            plans_created: 0,
653            executable_candidates_seen: 0,
654            validated_transactions: 0,
655            applied_transactions: 0,
656            blocked_or_plan_only_candidates: 0,
657            evidence_grade: before_map.evidence.grade,
658            analysis_depth: before_map.evidence.analysis_depth.clone(),
659        },
660        passes: Vec::new(),
661        total_planned_candidates: 0,
662        total_executed_candidates: 0,
663        total_skipped_candidates: 0,
664        budget_exhausted: false,
665        note: String::new(),
666        artifact_path: None,
667    };
668
669    let started_at = std::time::Instant::now();
670    let pass_count = config.max_passes.max(1);
671    for pass_index in 1..=pass_count {
672        if config
673            .budget
674            .is_some_and(|budget| started_at.elapsed() >= budget)
675        {
676            run.budget_exhausted = true;
677            break;
678        }
679        let plan = build_refactor_plan(
680            &root,
681            artifact_root,
682            &RefactorPlanConfig {
683                target: config.target.clone(),
684                policy_path: config.policy_path.clone(),
685                behavior_spec_path: config.behavior_spec_path.clone(),
686                max_files: config.max_files,
687            },
688        )?;
689        let executable = count_executable_candidates(
690            &plan,
691            config.allow_public_api_impact,
692            config.max_candidates,
693            config.max_tier,
694            config.min_evidence,
695        );
696        run.total_planned_candidates += plan.candidates.len();
697
698        let mut pass = AutopilotPass {
699            pass_index,
700            plan_id: plan.plan_id.clone(),
701            plan_hash: plan.plan_hash.clone(),
702            plan_artifact_path: plan.artifact_path.clone(),
703            planned_candidates: plan.candidates.len(),
704            executable_candidates: executable,
705            batch: None,
706            status: AutopilotPassStatus::Planned,
707            note: String::new(),
708        };
709
710        if executable == 0 {
711            pass.status = AutopilotPassStatus::NoExecutableCandidates;
712            pass.note = "no executable low-risk candidates remain for this pass".to_string();
713            run.passes.push(pass);
714            break;
715        }
716
717        let Some(plan_path) = plan.artifact_path.as_ref() else {
718            pass.status = AutopilotPassStatus::Rejected;
719            pass.note = "autopilot requires persisted plan artifacts before execution".to_string();
720            run.passes.push(pass);
721            break;
722        };
723
724        let mut validation_timeout = config.validation_timeout;
725        if let Some(budget) = config.budget {
726            let Some(remaining) = budget.checked_sub(started_at.elapsed()) else {
727                run.budget_exhausted = true;
728                pass.status = AutopilotPassStatus::NoExecutableCandidates;
729                pass.note = "budget exhausted before execution could start".to_string();
730                run.passes.push(pass);
731                break;
732            };
733            if remaining.is_zero() {
734                run.budget_exhausted = true;
735                pass.status = AutopilotPassStatus::NoExecutableCandidates;
736                pass.note = "budget exhausted before execution could start".to_string();
737                run.passes.push(pass);
738                break;
739            }
740            validation_timeout = validation_timeout.min(remaining);
741        }
742
743        let batch = apply_refactor_plan_batch(
744            &root,
745            artifact_root,
746            &RefactorBatchApplyConfig {
747                plan_path: PathBuf::from(plan_path),
748                apply: config.apply,
749                allow_public_api_impact: config.allow_public_api_impact,
750                validation_timeout,
751                max_candidates: config.max_candidates,
752                max_tier: config.max_tier,
753                min_evidence: config.min_evidence,
754            },
755        )?;
756        if config
757            .budget
758            .is_some_and(|budget| started_at.elapsed() >= budget)
759        {
760            run.budget_exhausted = true;
761        }
762        run.total_executed_candidates += batch.executed_candidates;
763        run.total_skipped_candidates += batch.skipped_candidates;
764        pass.status = autopilot_pass_status(&batch.status);
765        pass.note = batch.note.clone();
766        let should_stop = !config.apply
767            || matches!(
768                batch.status,
769                RefactorBatchApplyStatus::Rejected
770                    | RefactorBatchApplyStatus::StalePlan
771                    | RefactorBatchApplyStatus::NoExecutableCandidates
772                    | RefactorBatchApplyStatus::PartiallyApplied
773            )
774            || batch.executed_candidates == 0;
775        pass.batch = Some(batch);
776        run.passes.push(pass);
777        if should_stop {
778            break;
779        }
780    }
781
782    let after_map = if config.apply && run.total_executed_candidates > 0 {
783        Some(build_codebase_map(&root, artifact_root, &map_config)?)
784    } else {
785        None
786    };
787    run.quality_after = after_map.map(|map| map.quality);
788    run.status = autopilot_status(config.apply, &run.passes, run.total_executed_candidates);
789    run.note = autopilot_note(&run);
790    run.execution_summary = autopilot_execution_summary(&run);
791    persist_autopilot_run(artifact_root, run)
792}
793
794pub fn apply_refactor_plan_candidate(
795    root: &Path,
796    artifact_root: Option<&Path>,
797    config: &RefactorApplyConfig,
798) -> anyhow::Result<RefactorApplyRun> {
799    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
800    let plan_content = std::fs::read_to_string(&config.plan_path)?;
801    let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
802    let mode = if config.apply {
803        RefactorApplyMode::Apply
804    } else {
805        RefactorApplyMode::Review
806    };
807    let mut run = RefactorApplyRun {
808        schema_version: "0.6".to_string(),
809        root: root.display().to_string(),
810        plan_path: config.plan_path.display().to_string(),
811        plan_id: plan.plan_id.clone(),
812        plan_hash: plan.plan_hash.clone(),
813        candidate_id: config.candidate_id.clone(),
814        candidate_hash: None,
815        mode,
816        status: RefactorApplyStatus::Rejected,
817        public_api_impact_allowed: config.allow_public_api_impact,
818        stale_files: Vec::new(),
819        hardening_run: None,
820        note: String::new(),
821        artifact_path: None,
822    };
823
824    let actual_plan_hash = refactor_plan_hash(&plan);
825    if actual_plan_hash != plan.plan_hash {
826        run.status = RefactorApplyStatus::Rejected;
827        run.note = format!(
828            "plan hash mismatch: expected {} but recomputed {}",
829            plan.plan_hash, actual_plan_hash
830        );
831        return persist_apply_run(artifact_root, run);
832    }
833
834    let stale_files = stale_source_files(&root, &plan.source_snapshots)?;
835    if !stale_files.is_empty() {
836        run.status = RefactorApplyStatus::StalePlan;
837        run.stale_files = stale_files;
838        run.note =
839            "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
840        return persist_apply_run(artifact_root, run);
841    }
842
843    let Some(candidate) = plan
844        .candidates
845        .iter()
846        .find(|candidate| candidate.id == config.candidate_id)
847    else {
848        run.status = RefactorApplyStatus::Rejected;
849        run.note = "candidate id was not found in the refactor plan".to_string();
850        return persist_apply_run(artifact_root, run);
851    };
852    run.candidate_hash = Some(candidate.candidate_hash.clone());
853
854    let actual_candidate_hash = candidate_hash(candidate);
855    if actual_candidate_hash != candidate.candidate_hash {
856        run.status = RefactorApplyStatus::Rejected;
857        run.note = format!(
858            "candidate hash mismatch: expected {} but recomputed {}",
859            candidate.candidate_hash, actual_candidate_hash
860        );
861        return persist_apply_run(artifact_root, run);
862    }
863
864    if candidate.public_api_impact && !config.allow_public_api_impact {
865        run.status = RefactorApplyStatus::Rejected;
866        run.note = "candidate touches public API impact area; pass --allow-public-api-impact after human review".to_string();
867        return persist_apply_run(artifact_root, run);
868    }
869
870    if !candidate.evidence_satisfied {
871        run.status = RefactorApplyStatus::Unsupported;
872        run.note = format!(
873            "candidate requires {:?} evidence but plan evidence is {:?}",
874            candidate.required_evidence, plan.evidence.grade
875        );
876        return persist_apply_run(artifact_root, run);
877    }
878
879    if candidate.status != RefactorCandidateStatus::ApplyViaImprove
880        || !is_supported_mechanical_recipe(&candidate.recipe)
881    {
882        run.status = RefactorApplyStatus::Unsupported;
883        run.note = "candidate is plan-only; no executable recipe is available yet".to_string();
884        return persist_apply_run(artifact_root, run);
885    }
886
887    let hardening = run_hardening(
888        &root,
889        artifact_root,
890        &HardeningConfig {
891            target: Some(PathBuf::from(&candidate.file)),
892            policy_path: plan
893                .policy
894                .as_ref()
895                .map(|policy| PathBuf::from(policy.path.clone())),
896            behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
897            apply: config.apply,
898            max_files: 1,
899            validation_timeout: config.validation_timeout,
900        },
901    )?;
902
903    run.status = if config.apply {
904        if hardening.outcome.applied {
905            RefactorApplyStatus::Applied
906        } else {
907            RefactorApplyStatus::Rejected
908        }
909    } else if hardening.changes.is_empty() {
910        RefactorApplyStatus::Rejected
911    } else {
912        RefactorApplyStatus::Reviewed
913    };
914    run.note = format!(
915        "executed candidate through hardening transaction; hardening status: {:?}",
916        hardening.outcome.status
917    );
918    run.hardening_run = Some(hardening);
919    persist_apply_run(artifact_root, run)
920}
921
922pub fn apply_refactor_plan_batch(
923    root: &Path,
924    artifact_root: Option<&Path>,
925    config: &RefactorBatchApplyConfig,
926) -> anyhow::Result<RefactorBatchApplyRun> {
927    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
928    let plan_content = std::fs::read_to_string(&config.plan_path)?;
929    let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
930    let mode = if config.apply {
931        RefactorApplyMode::Apply
932    } else {
933        RefactorApplyMode::Review
934    };
935    let mut run = RefactorBatchApplyRun {
936        schema_version: "0.6".to_string(),
937        root: root.display().to_string(),
938        plan_path: config.plan_path.display().to_string(),
939        plan_id: plan.plan_id.clone(),
940        plan_hash: plan.plan_hash.clone(),
941        mode,
942        status: RefactorBatchApplyStatus::Rejected,
943        public_api_impact_allowed: config.allow_public_api_impact,
944        max_candidates: config.max_candidates,
945        requested_candidates: 0,
946        executed_candidates: 0,
947        skipped_candidates: 0,
948        steps: Vec::new(),
949        note: String::new(),
950        artifact_path: None,
951    };
952
953    let actual_plan_hash = refactor_plan_hash(&plan);
954    if actual_plan_hash != plan.plan_hash {
955        run.status = RefactorBatchApplyStatus::Rejected;
956        run.note = format!(
957            "plan hash mismatch: expected {} but recomputed {}",
958            plan.plan_hash, actual_plan_hash
959        );
960        return persist_batch_apply_run(artifact_root, run);
961    }
962
963    let initial_stale_files = stale_source_files(&root, &plan.source_snapshots)?;
964    if !initial_stale_files.is_empty() {
965        run.status = RefactorBatchApplyStatus::StalePlan;
966        run.steps = initial_stale_files
967            .into_iter()
968            .map(|stale| RefactorBatchCandidateRun {
969                candidate_id: String::new(),
970                candidate_hash: None,
971                file: stale.file.clone(),
972                status: RefactorApplyStatus::StalePlan,
973                stale_file: Some(stale),
974                hardening_run: None,
975                note: "source snapshot no longer matches the workspace".to_string(),
976            })
977            .collect();
978        run.note =
979            "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
980        return persist_batch_apply_run(artifact_root, run);
981    }
982
983    let queue = executable_candidate_queue(&plan, config);
984    run.requested_candidates = queue.len();
985    if queue.is_empty() {
986        run.status = RefactorBatchApplyStatus::NoExecutableCandidates;
987        run.note = "no executable low-risk candidates were available in the plan".to_string();
988        return persist_batch_apply_run(artifact_root, run);
989    }
990
991    for candidate in queue {
992        let mut step = RefactorBatchCandidateRun {
993            candidate_id: candidate.id.clone(),
994            candidate_hash: Some(candidate.candidate_hash.clone()),
995            file: candidate.file.clone(),
996            status: RefactorApplyStatus::Rejected,
997            stale_file: None,
998            hardening_run: None,
999            note: String::new(),
1000        };
1001
1002        let actual_candidate_hash = candidate_hash(candidate);
1003        if actual_candidate_hash != candidate.candidate_hash {
1004            step.note = format!(
1005                "candidate hash mismatch: expected {} but recomputed {}",
1006                candidate.candidate_hash, actual_candidate_hash
1007            );
1008            run.skipped_candidates += 1;
1009            run.steps.push(step);
1010            if config.apply {
1011                break;
1012            }
1013            continue;
1014        }
1015
1016        if let Some(stale) = stale_file_for_candidate(&root, &plan, &candidate.file)? {
1017            step.status = RefactorApplyStatus::StalePlan;
1018            step.stale_file = Some(stale);
1019            step.note =
1020                "candidate source file changed after planning; re-run mdx-rust plan".to_string();
1021            run.skipped_candidates += 1;
1022            run.steps.push(step);
1023            if config.apply {
1024                break;
1025            }
1026            continue;
1027        }
1028
1029        let hardening = run_hardening(
1030            &root,
1031            artifact_root,
1032            &HardeningConfig {
1033                target: Some(PathBuf::from(&candidate.file)),
1034                policy_path: plan
1035                    .policy
1036                    .as_ref()
1037                    .map(|policy| PathBuf::from(policy.path.clone())),
1038                behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
1039                apply: config.apply,
1040                max_files: 1,
1041                validation_timeout: config.validation_timeout,
1042            },
1043        )?;
1044
1045        step.status = if config.apply {
1046            if hardening.outcome.applied {
1047                RefactorApplyStatus::Applied
1048            } else {
1049                RefactorApplyStatus::Rejected
1050            }
1051        } else if hardening.changes.is_empty() {
1052            RefactorApplyStatus::Rejected
1053        } else {
1054            RefactorApplyStatus::Reviewed
1055        };
1056        step.note = format!(
1057            "executed candidate through hardening transaction; hardening status: {:?}",
1058            hardening.outcome.status
1059        );
1060        step.hardening_run = Some(hardening);
1061
1062        if matches!(
1063            step.status,
1064            RefactorApplyStatus::Reviewed | RefactorApplyStatus::Applied
1065        ) {
1066            run.executed_candidates += 1;
1067        } else {
1068            run.skipped_candidates += 1;
1069        }
1070
1071        let failed_apply_step = config.apply && step.status != RefactorApplyStatus::Applied;
1072        run.steps.push(step);
1073        if failed_apply_step {
1074            break;
1075        }
1076    }
1077
1078    run.status = batch_status(
1079        config.apply,
1080        run.executed_candidates,
1081        run.requested_candidates,
1082    );
1083    run.note = format!(
1084        "processed {} executable candidate(s); executed {}, skipped {}",
1085        run.requested_candidates, run.executed_candidates, run.skipped_candidates
1086    );
1087    persist_batch_apply_run(artifact_root, run)
1088}
1089
1090fn summarize_impact(
1091    files: &[RefactorFileSummary],
1092    module_edge_count: usize,
1093    findings: &[HardeningFinding],
1094    patchable_hardening_changes: usize,
1095) -> RefactorImpactSummary {
1096    let public_item_count = files.iter().map(|file| file.public_item_count).sum();
1097    let public_files = files
1098        .iter()
1099        .filter(|file| file.public_item_count > 0)
1100        .count();
1101    let oversized_files = files.iter().filter(|file| file.line_count >= 300).count();
1102    let oversized_functions = files
1103        .iter()
1104        .filter(|file| file.largest_function_lines >= 80)
1105        .count();
1106    let review_only_findings = findings.iter().filter(|finding| !finding.patchable).count();
1107    let risk_level = if public_item_count > 10 || oversized_files > 2 {
1108        RefactorRiskLevel::High
1109    } else if public_item_count > 0 || oversized_files > 0 || oversized_functions > 0 {
1110        RefactorRiskLevel::Medium
1111    } else {
1112        RefactorRiskLevel::Low
1113    };
1114
1115    RefactorImpactSummary {
1116        files_scanned: files.len(),
1117        public_item_count,
1118        public_files,
1119        module_edge_count,
1120        patchable_hardening_changes,
1121        review_only_findings,
1122        oversized_files,
1123        oversized_functions,
1124        risk_level,
1125    }
1126}
1127
1128fn summarize_quality(
1129    files: &[RefactorFileSummary],
1130    findings: &[HardeningFinding],
1131    impact: &RefactorImpactSummary,
1132) -> CodebaseQualitySummary {
1133    let patchable_findings = findings.iter().filter(|finding| finding.patchable).count();
1134    let review_only_findings = findings.len().saturating_sub(patchable_findings);
1135    let files_with_tests = files.iter().filter(|file| file.has_tests).count();
1136    let test_coverage_signal = if files.is_empty() {
1137        TestCoverageSignal::Unknown
1138    } else if files_with_tests > 0 {
1139        TestCoverageSignal::Present
1140    } else {
1141        TestCoverageSignal::Sparse
1142    };
1143
1144    let mut score = 0usize;
1145    score += patchable_findings.saturating_mul(8);
1146    score += review_only_findings.saturating_mul(4);
1147    score += impact.oversized_files.saturating_mul(10);
1148    score += impact.oversized_functions.saturating_mul(7);
1149    score += impact.public_files.saturating_mul(2);
1150    if test_coverage_signal == TestCoverageSignal::Sparse {
1151        score += 12;
1152    }
1153    let debt_score = score.min(100) as u8;
1154    let grade = if debt_score >= 70 {
1155        CodebaseQualityGrade::HighRisk
1156    } else if debt_score >= 35 {
1157        CodebaseQualityGrade::NeedsWork
1158    } else if debt_score >= 10 {
1159        CodebaseQualityGrade::Good
1160    } else {
1161        CodebaseQualityGrade::Excellent
1162    };
1163
1164    CodebaseQualitySummary {
1165        grade,
1166        debt_score,
1167        patchable_findings,
1168        review_only_findings,
1169        public_api_pressure: impact.public_item_count,
1170        oversized_files: impact.oversized_files,
1171        oversized_functions: impact.oversized_functions,
1172        test_coverage_signal,
1173    }
1174}
1175
1176fn summarize_evidence(
1177    workspace: &WorkspaceSummary,
1178    files: &[RefactorFileSummary],
1179    gates: &[CapabilityGate],
1180    has_behavior_spec: bool,
1181) -> EvidenceSummary {
1182    let has_tests = files.iter().any(|file| file.has_tests);
1183    let has_nextest = gates
1184        .iter()
1185        .any(|gate| gate.id == "nextest" && gate.available);
1186    let has_coverage_tool = gates
1187        .iter()
1188        .any(|gate| gate.id == "llvm-cov" && gate.available);
1189    let has_mutation_tool = gates
1190        .iter()
1191        .any(|gate| gate.id == "mutants" && gate.available);
1192
1193    let grade = if !workspace.cargo_metadata_available {
1194        EvidenceGrade::None
1195    } else if has_tests || has_behavior_spec || has_nextest {
1196        EvidenceGrade::Tested
1197    } else {
1198        EvidenceGrade::Compiled
1199    };
1200    let max_autonomous_tier = max_tier_for_evidence(grade);
1201    let analysis_depth = analysis_depth_for_evidence(grade);
1202
1203    let signals = vec![
1204        EvidenceSignal {
1205            id: "cargo-metadata".to_string(),
1206            label: "Cargo metadata".to_string(),
1207            present: workspace.cargo_metadata_available,
1208            detail: if workspace.cargo_metadata_available {
1209                "workspace can be inspected and compile gates can run".to_string()
1210            } else {
1211                "no Cargo metadata was available for this target".to_string()
1212            },
1213        },
1214        EvidenceSignal {
1215            id: "tests-or-behavior-evals".to_string(),
1216            label: "Tests or behavior evals".to_string(),
1217            present: has_tests || has_behavior_spec,
1218            detail: if has_behavior_spec {
1219                "behavior eval spec was supplied".to_string()
1220            } else if has_tests {
1221                "at least one scanned file contains Rust test markers".to_string()
1222            } else {
1223                "no tests or behavior eval spec were detected for the scanned target".to_string()
1224            },
1225        },
1226        EvidenceSignal {
1227            id: "coverage-tool".to_string(),
1228            label: "Coverage tooling".to_string(),
1229            present: has_coverage_tool,
1230            detail: "cargo-llvm-cov availability is detected, but v0.6 does not run coverage automatically".to_string(),
1231        },
1232        EvidenceSignal {
1233            id: "mutation-tool".to_string(),
1234            label: "Mutation tooling".to_string(),
1235            present: has_mutation_tool,
1236            detail: "cargo-mutants availability is detected, but v0.6 does not run mutation tests automatically".to_string(),
1237        },
1238    ];
1239
1240    let mut unlock_suggestions = Vec::new();
1241    if grade == EvidenceGrade::None {
1242        unlock_suggestions.push(
1243            "Run mdx-rust from a Cargo workspace before allowing autonomous changes.".to_string(),
1244        );
1245    }
1246    if grade < EvidenceGrade::Tested {
1247        unlock_suggestions.push(
1248            "Add Rust tests or pass --eval-spec to unlock tested evidence for future recipes."
1249                .to_string(),
1250        );
1251    }
1252    if !has_coverage_tool {
1253        unlock_suggestions
1254            .push("Install cargo-llvm-cov to prepare for covered Tier 2 recipe gates.".to_string());
1255    }
1256    if !has_mutation_tool {
1257        unlock_suggestions.push(
1258            "Install cargo-mutants to prepare for hardened Tier 2 and Tier 3 recipe gates."
1259                .to_string(),
1260        );
1261    }
1262
1263    EvidenceSummary {
1264        grade,
1265        max_autonomous_tier,
1266        analysis_depth,
1267        signals,
1268        unlocked_recipe_tiers: unlocked_recipe_tiers(grade),
1269        unlock_suggestions,
1270    }
1271}
1272
1273fn analysis_depth_for_evidence(grade: EvidenceGrade) -> EvidenceAnalysisDepth {
1274    match grade {
1275        EvidenceGrade::None => EvidenceAnalysisDepth::None,
1276        EvidenceGrade::Compiled => EvidenceAnalysisDepth::Mechanical,
1277        EvidenceGrade::Tested => EvidenceAnalysisDepth::BoundaryAware,
1278        EvidenceGrade::Covered | EvidenceGrade::Hardened | EvidenceGrade::Proven => {
1279            EvidenceAnalysisDepth::Structural
1280        }
1281    }
1282}
1283
1284fn unlocked_recipe_tiers(grade: EvidenceGrade) -> Vec<String> {
1285    let mut tiers = Vec::new();
1286    if grade >= EvidenceGrade::Compiled {
1287        tiers.push("Tier 1 executable mechanical recipes".to_string());
1288    }
1289    if grade >= EvidenceGrade::Tested {
1290        tiers.push("Tier 2 boundary review candidates".to_string());
1291    }
1292    if grade >= EvidenceGrade::Covered {
1293        tiers.push("Tier 2 structural candidates in review".to_string());
1294    }
1295    if grade >= EvidenceGrade::Hardened {
1296        tiers.push("Tier 3 semantic candidates in review".to_string());
1297    }
1298    tiers
1299}
1300
1301fn max_tier_for_evidence(grade: EvidenceGrade) -> u8 {
1302    match grade {
1303        EvidenceGrade::None => 0,
1304        EvidenceGrade::Compiled | EvidenceGrade::Tested => 1,
1305        EvidenceGrade::Covered => 2,
1306        EvidenceGrade::Hardened | EvidenceGrade::Proven => 3,
1307    }
1308}
1309
1310fn capability_gates() -> Vec<CapabilityGate> {
1311    vec![
1312        CapabilityGate {
1313            id: "nextest".to_string(),
1314            label: "cargo-nextest".to_string(),
1315            available: cargo_subcommand_exists("nextest"),
1316            command: "cargo nextest run".to_string(),
1317            purpose: "fast, isolated Rust test execution for behavior gates".to_string(),
1318        },
1319        CapabilityGate {
1320            id: "llvm-cov".to_string(),
1321            label: "cargo-llvm-cov".to_string(),
1322            available: cargo_subcommand_exists("llvm-cov"),
1323            command: "cargo llvm-cov".to_string(),
1324            purpose: "coverage evidence before broad autonomous refactoring".to_string(),
1325        },
1326        CapabilityGate {
1327            id: "mutants".to_string(),
1328            label: "cargo-mutants".to_string(),
1329            available: cargo_subcommand_exists("mutants"),
1330            command: "cargo mutants".to_string(),
1331            purpose: "mutation testing signal for high-value refactor targets".to_string(),
1332        },
1333        CapabilityGate {
1334            id: "semver-checks".to_string(),
1335            label: "cargo-semver-checks".to_string(),
1336            available: cargo_subcommand_exists("semver-checks"),
1337            command: "cargo semver-checks".to_string(),
1338            purpose: "public API compatibility gate for library refactors".to_string(),
1339        },
1340    ]
1341}
1342
1343fn recommended_actions(
1344    quality: &CodebaseQualitySummary,
1345    impact: &RefactorImpactSummary,
1346    gates: &[CapabilityGate],
1347    evidence: &EvidenceSummary,
1348) -> Vec<String> {
1349    let mut actions = Vec::new();
1350    if quality.patchable_findings > 0 && evidence.grade >= EvidenceGrade::Compiled {
1351        actions.push(
1352            "Run mdx-rust autopilot --apply to execute low-risk Tier 1 mechanical hardening passes."
1353                .to_string(),
1354        );
1355    } else if quality.patchable_findings > 0 {
1356        actions.push(
1357            "Autonomous execution is blocked until this target has at least compiled evidence."
1358                .to_string(),
1359        );
1360    }
1361    if quality.review_only_findings > 0 {
1362        actions.push(
1363            "Review security-sensitive findings before enabling broader recipes.".to_string(),
1364        );
1365    }
1366    if impact.oversized_files > 0 || impact.oversized_functions > 0 {
1367        actions.push(
1368            "Use mdx-rust plan to stage larger module and function refactors behind behavior gates."
1369                .to_string(),
1370        );
1371    }
1372    if quality.public_api_pressure > 0
1373        && gates
1374            .iter()
1375            .any(|gate| gate.id == "semver-checks" && !gate.available)
1376    {
1377        actions.push(
1378            "Install cargo-semver-checks before allowing public API impacting refactors."
1379                .to_string(),
1380        );
1381    }
1382    if quality.test_coverage_signal == TestCoverageSignal::Sparse {
1383        actions.push(
1384            "Add a behavior eval spec or stronger Rust tests before broad autonomous apply."
1385                .to_string(),
1386        );
1387    }
1388    actions.extend(evidence.unlock_suggestions.iter().cloned());
1389    if actions.is_empty() {
1390        actions.push(
1391            "No immediate autonomous changes found. Keep policy and behavior gates current."
1392                .to_string(),
1393        );
1394    }
1395    actions
1396}
1397
1398fn cargo_subcommand_exists(name: &str) -> bool {
1399    let command = format!("cargo-{name}");
1400    let Some(path_var) = std::env::var_os("PATH") else {
1401        return false;
1402    };
1403    std::env::split_paths(&path_var).any(|dir| dir.join(&command).is_file())
1404}
1405
1406fn hardening_candidates(
1407    findings: &[HardeningFinding],
1408    config: &RefactorPlanConfig,
1409    evidence: &EvidenceSummary,
1410) -> Vec<RefactorCandidate> {
1411    findings
1412        .iter()
1413        .filter_map(|finding| {
1414            let file = finding.file.display().to_string();
1415            let required_evidence = if finding.patchable {
1416                EvidenceGrade::Compiled
1417            } else {
1418                EvidenceGrade::Tested
1419            };
1420            let evidence_satisfied = evidence.grade >= required_evidence;
1421            let recipe = recipe_for_hardening_strategy(&finding.strategy);
1422            if !finding.patchable && !evidence_satisfied {
1423                return None;
1424            }
1425
1426            Some(RefactorCandidate {
1427                id: format!("plan-hardening-{}-{}", sanitize_id(&file), finding.line),
1428                candidate_hash: String::new(),
1429                recipe,
1430                title: finding.title.clone(),
1431                rationale: if finding.patchable {
1432                    "Patchable Tier 1 mechanical hardening can be applied through the existing isolated validation transaction.".to_string()
1433                } else {
1434                    "Higher-evidence review candidate surfaced from security or boundary analysis; it remains plan-only until a safe executable recipe exists.".to_string()
1435                },
1436                file: file.clone(),
1437                line: finding.line,
1438                risk: risk_for_hardening_strategy(&finding.strategy),
1439                status: if evidence_satisfied {
1440                    if finding.patchable {
1441                        RefactorCandidateStatus::ApplyViaImprove
1442                    } else {
1443                        RefactorCandidateStatus::PlanOnly
1444                    }
1445                } else {
1446                    RefactorCandidateStatus::PlanOnly
1447                },
1448                tier: if finding.patchable {
1449                    RecipeTier::Tier1
1450                } else {
1451                    RecipeTier::Tier2
1452                },
1453                required_evidence,
1454                evidence_satisfied,
1455                public_api_impact: false,
1456                apply_command: (finding.patchable && evidence_satisfied)
1457                    .then(|| apply_command(&file, config)),
1458                required_gates: if finding.patchable {
1459                    required_gates(config.behavior_spec_path.is_some())
1460                } else {
1461                    vec![
1462                        "human review of boundary contract".to_string(),
1463                        "behavior evals or tests must cover the boundary".to_string(),
1464                        "future executable recipe must route through hardening transactions"
1465                            .to_string(),
1466                    ]
1467                },
1468            })
1469        })
1470        .collect()
1471}
1472
1473fn recipe_for_hardening_strategy(
1474    strategy: &mdx_rust_analysis::HardeningStrategy,
1475) -> RefactorRecipe {
1476    match strategy {
1477        mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening => {
1478            RefactorRecipe::BorrowParameterTightening
1479        }
1480        mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation => {
1481            RefactorRecipe::ErrorContextPropagation
1482        }
1483        mdx_rust_analysis::HardeningStrategy::IteratorCloned => RefactorRecipe::IteratorCloned,
1484        mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn => {
1485            RefactorRecipe::MustUsePublicReturn
1486        }
1487        mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => {
1488            RefactorRecipe::BoundaryValidationReview
1489        }
1490        mdx_rust_analysis::HardeningStrategy::EnvAccessReview
1491        | mdx_rust_analysis::HardeningStrategy::FileIoReview => {
1492            RefactorRecipe::BoundaryValidationReview
1493        }
1494        mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
1495        | mdx_rust_analysis::HardeningStrategy::UnsafeReview => {
1496            RefactorRecipe::SecurityBoundaryReview
1497        }
1498        _ => RefactorRecipe::ContextualErrorHardening,
1499    }
1500}
1501
1502fn risk_for_hardening_strategy(
1503    strategy: &mdx_rust_analysis::HardeningStrategy,
1504) -> RefactorRiskLevel {
1505    match strategy {
1506        mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
1507        | mdx_rust_analysis::HardeningStrategy::UnsafeReview => RefactorRiskLevel::High,
1508        mdx_rust_analysis::HardeningStrategy::EnvAccessReview
1509        | mdx_rust_analysis::HardeningStrategy::FileIoReview
1510        | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => RefactorRiskLevel::Medium,
1511        _ => RefactorRiskLevel::Low,
1512    }
1513}
1514
1515fn structural_candidates(
1516    files: &[RefactorFileSummary],
1517    evidence: &EvidenceSummary,
1518) -> Vec<RefactorCandidate> {
1519    let mut candidates = Vec::new();
1520    for file in files {
1521        let file_path = file.file.display().to_string();
1522        if file.line_count >= 300 {
1523            let required_evidence = EvidenceGrade::Covered;
1524            candidates.push(RefactorCandidate {
1525                id: format!("plan-split-module-{}", sanitize_id(&file_path)),
1526                candidate_hash: String::new(),
1527                recipe: RefactorRecipe::SplitModuleCandidate,
1528                title: "Split oversized module".to_string(),
1529                rationale: format!(
1530                    "{} has {} lines. Split only after reviewing public API and module edges.",
1531                    file_path, file.line_count
1532                ),
1533                file: file_path.clone(),
1534                line: 1,
1535                risk: if file.public_item_count > 0 {
1536                    RefactorRiskLevel::High
1537                } else {
1538                    RefactorRiskLevel::Medium
1539                },
1540                status: RefactorCandidateStatus::NeedsHumanDesign,
1541                tier: RecipeTier::Tier2,
1542                required_evidence,
1543                evidence_satisfied: evidence.grade >= required_evidence,
1544                public_api_impact: file.public_item_count > 0,
1545                apply_command: None,
1546                required_gates: vec![
1547                    "human design review".to_string(),
1548                    "cargo check".to_string(),
1549                    "cargo clippy -- -D warnings".to_string(),
1550                    "behavior evals when configured".to_string(),
1551                ],
1552            });
1553        }
1554
1555        if file.largest_function_lines >= 80 {
1556            let required_evidence = EvidenceGrade::Covered;
1557            candidates.push(RefactorCandidate {
1558                id: format!("plan-extract-function-{}", sanitize_id(&file_path)),
1559                candidate_hash: String::new(),
1560                recipe: RefactorRecipe::ExtractFunctionCandidate,
1561                title: "Extract long function".to_string(),
1562                rationale: format!(
1563                    "Largest function in {} is {} lines. Extract only with behavior coverage in place.",
1564                    file_path, file.largest_function_lines
1565                ),
1566                file: file_path.clone(),
1567                line: 1,
1568                risk: RefactorRiskLevel::Medium,
1569                status: RefactorCandidateStatus::PlanOnly,
1570                tier: RecipeTier::Tier2,
1571                required_evidence,
1572                evidence_satisfied: evidence.grade >= required_evidence,
1573                public_api_impact: file.public_item_count > 0,
1574                apply_command: None,
1575                required_gates: vec![
1576                    "targeted tests or behavior evals".to_string(),
1577                    "cargo check".to_string(),
1578                    "cargo clippy -- -D warnings".to_string(),
1579                ],
1580            });
1581        }
1582
1583        if file.public_item_count > 0 {
1584            let required_evidence = EvidenceGrade::Tested;
1585            candidates.push(RefactorCandidate {
1586                id: format!("plan-public-api-{}", sanitize_id(&file_path)),
1587                candidate_hash: String::new(),
1588                recipe: RefactorRecipe::PublicApiReview,
1589                title: "Protect public API before refactoring".to_string(),
1590                rationale: format!(
1591                    "{} exposes {} public item(s). Treat signature changes as semver-impacting.",
1592                    file_path, file.public_item_count
1593                ),
1594                file: file_path,
1595                line: 1,
1596                risk: RefactorRiskLevel::Medium,
1597                status: RefactorCandidateStatus::PlanOnly,
1598                tier: RecipeTier::Tier1,
1599                required_evidence,
1600                evidence_satisfied: evidence.grade >= required_evidence,
1601                public_api_impact: true,
1602                apply_command: None,
1603                required_gates: vec![
1604                    "public API review".to_string(),
1605                    "docs and changelog review for exported changes".to_string(),
1606                ],
1607            });
1608        }
1609    }
1610
1611    candidates
1612}
1613
1614fn required_gates(has_behavior_spec: bool) -> Vec<String> {
1615    let mut gates = vec![
1616        "cargo check".to_string(),
1617        "cargo clippy -- -D warnings".to_string(),
1618        "review plan artifact before applying".to_string(),
1619    ];
1620    if has_behavior_spec {
1621        gates.push("behavior eval spec must pass in isolation and after apply".to_string());
1622    }
1623    gates
1624}
1625
1626fn apply_command(file: &str, config: &RefactorPlanConfig) -> String {
1627    let mut command = format!("mdx-rust improve {} --apply", shell_word_str(file));
1628    if let Some(policy) = &config.policy_path {
1629        command.push_str(&format!(" --policy {}", shell_word_path(policy)));
1630    }
1631    if let Some(eval_spec) = &config.behavior_spec_path {
1632        command.push_str(&format!(" --eval-spec {}", shell_word_path(eval_spec)));
1633    }
1634    command
1635}
1636
1637fn shell_word_path(path: &Path) -> String {
1638    shell_word_str(&path.display().to_string())
1639}
1640
1641fn shell_word_str(value: &str) -> String {
1642    if value
1643        .chars()
1644        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
1645    {
1646        value.to_string()
1647    } else {
1648        format!("'{}'", value.replace('\'', "'\\''"))
1649    }
1650}
1651
1652fn plan_id(
1653    root: &Path,
1654    config: &RefactorPlanConfig,
1655    impact: &RefactorImpactSummary,
1656    candidates: &[RefactorCandidate],
1657) -> String {
1658    let mut bytes = Vec::new();
1659    bytes.extend_from_slice(root.display().to_string().as_bytes());
1660    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
1661    bytes.extend_from_slice(format!("{:?}", config.policy_path).as_bytes());
1662    bytes.extend_from_slice(format!("{:?}", config.behavior_spec_path).as_bytes());
1663    bytes.extend_from_slice(format!("{impact:?}").as_bytes());
1664    bytes.extend_from_slice(format!("{candidates:?}").as_bytes());
1665    stable_hash_hex(&bytes)
1666}
1667
1668fn codebase_map_id(
1669    root: &Path,
1670    config: &CodebaseMapConfig,
1671    quality: &CodebaseQualitySummary,
1672    impact: &RefactorImpactSummary,
1673) -> String {
1674    let mut bytes = Vec::new();
1675    bytes.extend_from_slice(root.display().to_string().as_bytes());
1676    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
1677    bytes.extend_from_slice(format!("{quality:?}").as_bytes());
1678    bytes.extend_from_slice(format!("{impact:?}").as_bytes());
1679    stable_hash_hex(&bytes)
1680}
1681
1682fn codebase_map_hash(map: &CodebaseMap) -> String {
1683    let mut bytes = Vec::new();
1684    bytes.extend_from_slice(map.schema_version.as_bytes());
1685    bytes.extend_from_slice(map.map_id.as_bytes());
1686    bytes.extend_from_slice(map.root.as_bytes());
1687    bytes.extend_from_slice(format!("{:?}", map.target).as_bytes());
1688    bytes.extend_from_slice(format!("{:?}", map.quality).as_bytes());
1689    bytes.extend_from_slice(format!("{:?}", map.evidence).as_bytes());
1690    bytes.extend_from_slice(format!("{:?}", map.impact).as_bytes());
1691    bytes.extend_from_slice(format!("{:?}", map.files).as_bytes());
1692    bytes.extend_from_slice(format!("{:?}", map.module_edges).as_bytes());
1693    bytes.extend_from_slice(format!("{:?}", map.findings).as_bytes());
1694    stable_hash_hex(&bytes)
1695}
1696
1697fn autopilot_run_id(root: &Path, config: &AutopilotConfig, map: &CodebaseMap) -> String {
1698    let mut bytes = Vec::new();
1699    bytes.extend_from_slice(root.display().to_string().as_bytes());
1700    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
1701    bytes.extend_from_slice(config.apply.to_string().as_bytes());
1702    bytes.extend_from_slice(config.max_passes.to_string().as_bytes());
1703    bytes.extend_from_slice(config.max_candidates.to_string().as_bytes());
1704    bytes.extend_from_slice(format!("{:?}", config.max_tier).as_bytes());
1705    bytes.extend_from_slice(format!("{:?}", config.min_evidence).as_bytes());
1706    bytes.extend_from_slice(map.map_hash.as_bytes());
1707    stable_hash_hex(&bytes)
1708}
1709
1710fn refactor_plan_hash(plan: &RefactorPlan) -> String {
1711    let mut bytes = Vec::new();
1712    bytes.extend_from_slice(plan.schema_version.as_bytes());
1713    bytes.extend_from_slice(plan.plan_id.as_bytes());
1714    bytes.extend_from_slice(plan.root.as_bytes());
1715    bytes.extend_from_slice(format!("{:?}", plan.target).as_bytes());
1716    bytes.extend_from_slice(format!("{:?}", plan.evidence).as_bytes());
1717    bytes.extend_from_slice(format!("{:?}", plan.impact).as_bytes());
1718    bytes.extend_from_slice(format!("{:?}", plan.source_snapshots).as_bytes());
1719    bytes.extend_from_slice(format!("{:?}", plan.module_edges).as_bytes());
1720    bytes.extend_from_slice(format!("{:?}", plan.candidates).as_bytes());
1721    stable_hash_hex(&bytes)
1722}
1723
1724fn candidate_hash(candidate: &RefactorCandidate) -> String {
1725    let mut bytes = Vec::new();
1726    bytes.extend_from_slice(candidate.id.as_bytes());
1727    bytes.extend_from_slice(format!("{:?}", candidate.recipe).as_bytes());
1728    bytes.extend_from_slice(candidate.title.as_bytes());
1729    bytes.extend_from_slice(candidate.rationale.as_bytes());
1730    bytes.extend_from_slice(candidate.file.as_bytes());
1731    bytes.extend_from_slice(candidate.line.to_string().as_bytes());
1732    bytes.extend_from_slice(format!("{:?}", candidate.risk).as_bytes());
1733    bytes.extend_from_slice(format!("{:?}", candidate.status).as_bytes());
1734    bytes.extend_from_slice(format!("{:?}", candidate.tier).as_bytes());
1735    bytes.extend_from_slice(format!("{:?}", candidate.required_evidence).as_bytes());
1736    bytes.extend_from_slice(candidate.evidence_satisfied.to_string().as_bytes());
1737    bytes.extend_from_slice(candidate.public_api_impact.to_string().as_bytes());
1738    bytes.extend_from_slice(format!("{:?}", candidate.apply_command).as_bytes());
1739    stable_hash_hex(&bytes)
1740}
1741
1742fn source_snapshots(
1743    root: &Path,
1744    files: &[RefactorFileSummary],
1745) -> anyhow::Result<Vec<SourceSnapshot>> {
1746    let mut snapshots = Vec::new();
1747    for file in files {
1748        let content = std::fs::read(root.join(&file.file))?;
1749        snapshots.push(SourceSnapshot {
1750            file: file.file.display().to_string(),
1751            hash: stable_hash_hex(&content),
1752        });
1753    }
1754    Ok(snapshots)
1755}
1756
1757fn stale_source_files(
1758    root: &Path,
1759    snapshots: &[SourceSnapshot],
1760) -> anyhow::Result<Vec<StaleSourceFile>> {
1761    let mut stale = Vec::new();
1762    for snapshot in snapshots {
1763        let rel = safe_relative_path(&snapshot.file)?;
1764        let actual_hash = std::fs::read(root.join(&rel))
1765            .map(|content| stable_hash_hex(&content))
1766            .unwrap_or_else(|_| "<missing>".to_string());
1767        if actual_hash != snapshot.hash {
1768            stale.push(StaleSourceFile {
1769                file: snapshot.file.clone(),
1770                expected_hash: snapshot.hash.clone(),
1771                actual_hash,
1772            });
1773        }
1774    }
1775    Ok(stale)
1776}
1777
1778fn stale_file_for_candidate(
1779    root: &Path,
1780    plan: &RefactorPlan,
1781    file: &str,
1782) -> anyhow::Result<Option<StaleSourceFile>> {
1783    let Some(snapshot) = plan
1784        .source_snapshots
1785        .iter()
1786        .find(|snapshot| snapshot.file == file)
1787    else {
1788        return Ok(Some(StaleSourceFile {
1789            file: file.to_string(),
1790            expected_hash: "<missing-snapshot>".to_string(),
1791            actual_hash: "<unknown>".to_string(),
1792        }));
1793    };
1794    let rel = safe_relative_path(&snapshot.file)?;
1795    let actual_hash = std::fs::read(root.join(&rel))
1796        .map(|content| stable_hash_hex(&content))
1797        .unwrap_or_else(|_| "<missing>".to_string());
1798    if actual_hash == snapshot.hash {
1799        Ok(None)
1800    } else {
1801        Ok(Some(StaleSourceFile {
1802            file: snapshot.file.clone(),
1803            expected_hash: snapshot.hash.clone(),
1804            actual_hash,
1805        }))
1806    }
1807}
1808
1809fn executable_candidate_queue<'a>(
1810    plan: &'a RefactorPlan,
1811    config: &RefactorBatchApplyConfig,
1812) -> Vec<&'a RefactorCandidate> {
1813    let mut queue = Vec::new();
1814    let mut seen_files = std::collections::BTreeSet::new();
1815    for candidate in &plan.candidates {
1816        if queue.len() >= config.max_candidates {
1817            break;
1818        }
1819        if candidate.status != RefactorCandidateStatus::ApplyViaImprove
1820            || !is_supported_mechanical_recipe(&candidate.recipe)
1821        {
1822            continue;
1823        }
1824        if !candidate.evidence_satisfied
1825            || candidate.required_evidence > plan.evidence.grade
1826            || plan.evidence.grade < config.min_evidence
1827            || candidate.tier > config.max_tier
1828        {
1829            continue;
1830        }
1831        if candidate.public_api_impact && !config.allow_public_api_impact {
1832            continue;
1833        }
1834        if seen_files.insert(candidate.file.clone()) {
1835            queue.push(candidate);
1836        }
1837    }
1838    queue
1839}
1840
1841fn is_supported_mechanical_recipe(recipe: &RefactorRecipe) -> bool {
1842    matches!(
1843        recipe,
1844        RefactorRecipe::BorrowParameterTightening
1845            | RefactorRecipe::ContextualErrorHardening
1846            | RefactorRecipe::ErrorContextPropagation
1847            | RefactorRecipe::IteratorCloned
1848            | RefactorRecipe::MustUsePublicReturn
1849    )
1850}
1851
1852fn count_executable_candidates(
1853    plan: &RefactorPlan,
1854    allow_public_api_impact: bool,
1855    max_candidates: usize,
1856    max_tier: RecipeTier,
1857    min_evidence: EvidenceGrade,
1858) -> usize {
1859    executable_candidate_queue(
1860        plan,
1861        &RefactorBatchApplyConfig {
1862            plan_path: PathBuf::new(),
1863            apply: false,
1864            allow_public_api_impact,
1865            validation_timeout: Duration::from_secs(1),
1866            max_candidates,
1867            max_tier,
1868            min_evidence,
1869        },
1870    )
1871    .len()
1872}
1873
1874fn autopilot_pass_status(status: &RefactorBatchApplyStatus) -> AutopilotPassStatus {
1875    match status {
1876        RefactorBatchApplyStatus::Reviewed => AutopilotPassStatus::Reviewed,
1877        RefactorBatchApplyStatus::Applied => AutopilotPassStatus::Applied,
1878        RefactorBatchApplyStatus::PartiallyApplied => AutopilotPassStatus::PartiallyApplied,
1879        RefactorBatchApplyStatus::NoExecutableCandidates => {
1880            AutopilotPassStatus::NoExecutableCandidates
1881        }
1882        RefactorBatchApplyStatus::Rejected | RefactorBatchApplyStatus::StalePlan => {
1883            AutopilotPassStatus::Rejected
1884        }
1885    }
1886}
1887
1888fn autopilot_status(
1889    apply: bool,
1890    passes: &[AutopilotPass],
1891    executed_candidates: usize,
1892) -> AutopilotStatus {
1893    if executed_candidates == 0 {
1894        if passes
1895            .iter()
1896            .any(|pass| pass.status == AutopilotPassStatus::Rejected)
1897        {
1898            AutopilotStatus::Rejected
1899        } else {
1900            AutopilotStatus::NoExecutableCandidates
1901        }
1902    } else if !apply {
1903        AutopilotStatus::Reviewed
1904    } else if passes
1905        .iter()
1906        .any(|pass| pass.status == AutopilotPassStatus::Rejected)
1907    {
1908        AutopilotStatus::PartiallyApplied
1909    } else {
1910        AutopilotStatus::Applied
1911    }
1912}
1913
1914fn autopilot_note(run: &AutopilotRun) -> String {
1915    match run.status {
1916        AutopilotStatus::Reviewed => format!(
1917            "reviewed {} candidate(s) across {} pass(es); rerun with --apply to land validated transactions",
1918            run.total_executed_candidates,
1919            run.passes.len()
1920        ),
1921        AutopilotStatus::Applied => format!(
1922            "applied {} candidate(s) across {} pass(es) with fresh plans before each pass",
1923            run.total_executed_candidates,
1924            run.passes.len()
1925        ),
1926        AutopilotStatus::PartiallyApplied => format!(
1927            "applied {} candidate(s) before an execution gate stopped the run",
1928            run.total_executed_candidates
1929        ),
1930        AutopilotStatus::NoExecutableCandidates => {
1931            if run.budget_exhausted {
1932                "budget exhausted before more executable work could run".to_string()
1933            } else {
1934                "no executable low-risk candidates were available".to_string()
1935            }
1936        }
1937        AutopilotStatus::Rejected => {
1938            "autopilot stopped because a planning or execution gate rejected the run".to_string()
1939        }
1940    }
1941}
1942
1943fn autopilot_execution_summary(run: &AutopilotRun) -> AutopilotExecutionSummary {
1944    let plans_created = run.passes.len();
1945    let executable_candidates_seen = run
1946        .passes
1947        .iter()
1948        .map(|pass| pass.executable_candidates)
1949        .sum();
1950    let validated_transactions = run
1951        .passes
1952        .iter()
1953        .filter_map(|pass| pass.batch.as_ref())
1954        .flat_map(|batch| batch.steps.iter())
1955        .filter(|step| {
1956            step.hardening_run
1957                .as_ref()
1958                .is_some_and(|hardening| hardening.outcome.isolated_validation_passed)
1959        })
1960        .count();
1961    let applied_transactions = run
1962        .passes
1963        .iter()
1964        .filter_map(|pass| pass.batch.as_ref())
1965        .flat_map(|batch| batch.steps.iter())
1966        .filter(|step| {
1967            step.hardening_run
1968                .as_ref()
1969                .is_some_and(|hardening| hardening.outcome.applied)
1970        })
1971        .count();
1972    let blocked_or_plan_only_candidates = run
1973        .total_planned_candidates
1974        .saturating_sub(executable_candidates_seen);
1975
1976    AutopilotExecutionSummary {
1977        plans_created,
1978        executable_candidates_seen,
1979        validated_transactions,
1980        applied_transactions,
1981        blocked_or_plan_only_candidates,
1982        evidence_grade: run.evidence.grade,
1983        analysis_depth: run.evidence.analysis_depth.clone(),
1984    }
1985}
1986
1987fn batch_status(apply: bool, executed: usize, requested: usize) -> RefactorBatchApplyStatus {
1988    if requested == 0 {
1989        RefactorBatchApplyStatus::NoExecutableCandidates
1990    } else if executed == 0 {
1991        RefactorBatchApplyStatus::Rejected
1992    } else if !apply {
1993        RefactorBatchApplyStatus::Reviewed
1994    } else if executed == requested {
1995        RefactorBatchApplyStatus::Applied
1996    } else {
1997        RefactorBatchApplyStatus::PartiallyApplied
1998    }
1999}
2000
2001fn safe_relative_path(value: &str) -> anyhow::Result<PathBuf> {
2002    let path = PathBuf::from(value);
2003    if path.is_absolute()
2004        || path.components().any(|component| {
2005            matches!(
2006                component,
2007                Component::ParentDir | Component::RootDir | Component::Prefix(_)
2008            )
2009        })
2010    {
2011        anyhow::bail!("refactor plan contains unscoped path: {value}");
2012    }
2013    Ok(path)
2014}
2015
2016fn sanitize_id(value: &str) -> String {
2017    value
2018        .chars()
2019        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
2020        .collect::<String>()
2021        .trim_matches('-')
2022        .to_string()
2023}
2024
2025fn persist_refactor_plan(artifact_root: &Path, plan: &RefactorPlan) -> anyhow::Result<PathBuf> {
2026    let dir = artifact_root.join("plans");
2027    std::fs::create_dir_all(&dir)?;
2028    let millis = std::time::SystemTime::now()
2029        .duration_since(std::time::UNIX_EPOCH)
2030        .map(|duration| duration.as_millis())
2031        .unwrap_or(0);
2032    Ok(dir.join(format!("refactor-plan-{millis}-{}.json", plan.plan_id)))
2033}
2034
2035fn persist_apply_run(
2036    artifact_root: Option<&Path>,
2037    mut run: RefactorApplyRun,
2038) -> anyhow::Result<RefactorApplyRun> {
2039    if let Some(artifact_root) = artifact_root {
2040        let dir = artifact_root.join("plans");
2041        std::fs::create_dir_all(&dir)?;
2042        let millis = std::time::SystemTime::now()
2043            .duration_since(std::time::UNIX_EPOCH)
2044            .map(|duration| duration.as_millis())
2045            .unwrap_or(0);
2046        let path = dir.join(format!(
2047            "apply-plan-{millis}-{}-{}.json",
2048            sanitize_id(&run.plan_id),
2049            sanitize_id(&run.candidate_id)
2050        ));
2051        run.artifact_path = Some(path.display().to_string());
2052        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
2053    }
2054    Ok(run)
2055}
2056
2057fn persist_batch_apply_run(
2058    artifact_root: Option<&Path>,
2059    mut run: RefactorBatchApplyRun,
2060) -> anyhow::Result<RefactorBatchApplyRun> {
2061    if let Some(artifact_root) = artifact_root {
2062        let dir = artifact_root.join("plans");
2063        std::fs::create_dir_all(&dir)?;
2064        let millis = std::time::SystemTime::now()
2065            .duration_since(std::time::UNIX_EPOCH)
2066            .map(|duration| duration.as_millis())
2067            .unwrap_or(0);
2068        let path = dir.join(format!(
2069            "apply-plan-batch-{millis}-{}.json",
2070            sanitize_id(&run.plan_id)
2071        ));
2072        run.artifact_path = Some(path.display().to_string());
2073        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
2074    }
2075    Ok(run)
2076}
2077
2078fn persist_codebase_map(artifact_root: &Path, map: &CodebaseMap) -> anyhow::Result<PathBuf> {
2079    let dir = artifact_root.join("maps");
2080    std::fs::create_dir_all(&dir)?;
2081    let millis = std::time::SystemTime::now()
2082        .duration_since(std::time::UNIX_EPOCH)
2083        .map(|duration| duration.as_millis())
2084        .unwrap_or(0);
2085    Ok(dir.join(format!(
2086        "codebase-map-{millis}-{}.json",
2087        sanitize_id(&map.map_id)
2088    )))
2089}
2090
2091fn persist_autopilot_run(
2092    artifact_root: Option<&Path>,
2093    mut run: AutopilotRun,
2094) -> anyhow::Result<AutopilotRun> {
2095    if let Some(artifact_root) = artifact_root {
2096        let dir = artifact_root.join("autopilot");
2097        std::fs::create_dir_all(&dir)?;
2098        let millis = std::time::SystemTime::now()
2099            .duration_since(std::time::UNIX_EPOCH)
2100            .map(|duration| duration.as_millis())
2101            .unwrap_or(0);
2102        let path = dir.join(format!(
2103            "autopilot-{millis}-{}.json",
2104            sanitize_id(&run.run_id)
2105        ));
2106        run.artifact_path = Some(path.display().to_string());
2107        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
2108    }
2109    Ok(run)
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114    use super::*;
2115    use tempfile::tempdir;
2116
2117    #[test]
2118    fn refactor_plan_points_patchable_changes_to_improve() {
2119        let dir = tempdir().unwrap();
2120        std::fs::write(
2121            dir.path().join("Cargo.toml"),
2122            r#"[package]
2123name = "plan-fixture"
2124version = "0.1.0"
2125edition = "2021"
2126
2127[dependencies]
2128anyhow = "1"
2129"#,
2130        )
2131        .unwrap();
2132        std::fs::create_dir_all(dir.path().join("src")).unwrap();
2133        std::fs::write(
2134            dir.path().join("src/lib.rs"),
2135            r#"pub fn load_config() -> anyhow::Result<String> {
2136    let content = std::fs::read_to_string("missing.toml").unwrap();
2137    Ok(content)
2138}
2139"#,
2140        )
2141        .unwrap();
2142
2143        let plan = build_refactor_plan(
2144            dir.path(),
2145            None,
2146            &RefactorPlanConfig {
2147                target: Some(PathBuf::from("src/lib.rs")),
2148                behavior_spec_path: Some(PathBuf::from(".mdx-rust/evals.json")),
2149                ..RefactorPlanConfig::default()
2150            },
2151        )
2152        .unwrap();
2153
2154        assert_eq!(plan.schema_version, "0.6");
2155        assert!(plan.candidates.iter().any(|candidate| candidate.status
2156            == RefactorCandidateStatus::ApplyViaImprove
2157            && candidate
2158                .apply_command
2159                .as_deref()
2160                .is_some_and(|command| command.contains("--eval-spec"))));
2161    }
2162
2163    #[test]
2164    fn tested_evidence_surfaces_boundary_review_candidates() {
2165        let dir = tempdir().unwrap();
2166        std::fs::write(
2167            dir.path().join("Cargo.toml"),
2168            r#"[package]
2169name = "tested-plan-fixture"
2170version = "0.1.0"
2171edition = "2021"
2172"#,
2173        )
2174        .unwrap();
2175        std::fs::create_dir_all(dir.path().join("src")).unwrap();
2176        std::fs::write(
2177            dir.path().join("src/lib.rs"),
2178            r#"pub fn shell(cmd: &str) {
2179    std::process::Command::new(cmd);
2180}
2181
2182#[cfg(test)]
2183mod tests {
2184    #[test]
2185    fn smoke() {
2186        assert_eq!(1, 1);
2187    }
2188}
2189"#,
2190        )
2191        .unwrap();
2192
2193        let plan = build_refactor_plan(
2194            dir.path(),
2195            None,
2196            &RefactorPlanConfig {
2197                target: Some(PathBuf::from("src/lib.rs")),
2198                ..RefactorPlanConfig::default()
2199            },
2200        )
2201        .unwrap();
2202
2203        assert_eq!(plan.evidence.grade, EvidenceGrade::Tested);
2204        assert_eq!(
2205            plan.evidence.analysis_depth,
2206            EvidenceAnalysisDepth::BoundaryAware
2207        );
2208        assert!(plan.candidates.iter().any(|candidate| candidate.status
2209            == RefactorCandidateStatus::PlanOnly
2210            && candidate.required_evidence == EvidenceGrade::Tested
2211            && candidate.tier == RecipeTier::Tier2));
2212    }
2213}