Skip to main content

mdx_rust_core/
refactor.rs

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