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