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