Skip to main content

mdx_rust_core/
refactor.rs

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