Skip to main content

mdx_rust_core/
refactor.rs

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