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