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 mdx_rust_analysis::{
13 analyze_hardening, analyze_refactor, HardeningAnalyzeConfig, HardeningEvidenceDepth,
14 HardeningFinding, ModuleEdge, RefactorAnalyzeConfig, RefactorFileSummary,
15};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use std::path::{Component, Path, PathBuf};
19use std::time::Duration;
20
21#[derive(Debug, Clone)]
22pub struct RefactorPlanConfig {
23 pub target: Option<PathBuf>,
24 pub policy_path: Option<PathBuf>,
25 pub behavior_spec_path: Option<PathBuf>,
26 pub max_files: usize,
27}
28
29impl Default for RefactorPlanConfig {
30 fn default() -> Self {
31 Self {
32 target: None,
33 policy_path: None,
34 behavior_spec_path: None,
35 max_files: 100,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct RefactorApplyConfig {
42 pub plan_path: PathBuf,
43 pub candidate_id: String,
44 pub apply: bool,
45 pub allow_public_api_impact: bool,
46 pub validation_timeout: Duration,
47}
48
49#[derive(Debug, Clone)]
50pub struct RefactorBatchApplyConfig {
51 pub plan_path: PathBuf,
52 pub apply: bool,
53 pub allow_public_api_impact: bool,
54 pub validation_timeout: Duration,
55 pub max_candidates: usize,
56 pub max_tier: RecipeTier,
57 pub min_evidence: EvidenceGrade,
58}
59
60#[derive(Debug, Clone)]
61pub struct CodebaseMapConfig {
62 pub target: Option<PathBuf>,
63 pub policy_path: Option<PathBuf>,
64 pub behavior_spec_path: Option<PathBuf>,
65 pub max_files: usize,
66}
67
68impl Default for CodebaseMapConfig {
69 fn default() -> Self {
70 Self {
71 target: None,
72 policy_path: None,
73 behavior_spec_path: None,
74 max_files: 250,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
80pub struct AutopilotConfig {
81 pub target: Option<PathBuf>,
82 pub policy_path: Option<PathBuf>,
83 pub behavior_spec_path: Option<PathBuf>,
84 pub apply: bool,
85 pub max_files: usize,
86 pub max_passes: usize,
87 pub max_candidates: usize,
88 pub validation_timeout: Duration,
89 pub allow_public_api_impact: bool,
90 pub max_tier: RecipeTier,
91 pub min_evidence: EvidenceGrade,
92 pub budget: Option<Duration>,
93}
94
95impl Default for AutopilotConfig {
96 fn default() -> Self {
97 Self {
98 target: None,
99 policy_path: None,
100 behavior_spec_path: None,
101 apply: false,
102 max_files: 250,
103 max_passes: 3,
104 max_candidates: 25,
105 validation_timeout: Duration::from_secs(180),
106 allow_public_api_impact: false,
107 max_tier: RecipeTier::Tier1,
108 min_evidence: EvidenceGrade::Compiled,
109 budget: None,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
115pub struct RefactorPlan {
116 pub schema_version: String,
117 pub plan_id: String,
118 pub plan_hash: String,
119 pub root: String,
120 pub target: Option<String>,
121 pub workspace: WorkspaceSummary,
122 pub policy: Option<ProjectPolicy>,
123 pub behavior_spec: Option<String>,
124 pub evidence: EvidenceSummary,
125 pub measured_evidence: Option<EvidenceArtifactRef>,
126 pub impact: RefactorImpactSummary,
127 pub source_snapshots: Vec<SourceSnapshot>,
128 pub files: Vec<RefactorFileSummary>,
129 pub module_edges: Vec<ModuleEdge>,
130 pub candidates: Vec<RefactorCandidate>,
131 pub required_gates: Vec<String>,
132 pub non_goals: Vec<String>,
133 pub artifact_path: Option<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
137pub struct CodebaseMap {
138 pub schema_version: String,
139 pub map_id: String,
140 pub map_hash: String,
141 pub root: String,
142 pub target: Option<String>,
143 pub workspace: WorkspaceSummary,
144 pub policy: Option<ProjectPolicy>,
145 pub behavior_spec: Option<String>,
146 pub evidence: EvidenceSummary,
147 pub measured_evidence: Option<EvidenceArtifactRef>,
148 pub quality: CodebaseQualitySummary,
149 pub capability_gates: Vec<CapabilityGate>,
150 pub impact: RefactorImpactSummary,
151 pub files: Vec<RefactorFileSummary>,
152 pub module_edges: Vec<ModuleEdge>,
153 pub findings: Vec<HardeningFinding>,
154 pub recommended_actions: Vec<String>,
155 pub artifact_path: Option<String>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
159pub struct CodebaseQualitySummary {
160 pub grade: CodebaseQualityGrade,
161 pub debt_score: u8,
162 pub patchable_findings: usize,
163 pub review_only_findings: usize,
164 pub public_api_pressure: usize,
165 pub oversized_files: usize,
166 pub oversized_functions: usize,
167 pub test_coverage_signal: TestCoverageSignal,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
171pub struct EvidenceSummary {
172 pub grade: EvidenceGrade,
173 pub max_autonomous_tier: u8,
174 pub analysis_depth: EvidenceAnalysisDepth,
175 pub signals: Vec<EvidenceSignal>,
176 pub unlocked_recipe_tiers: Vec<String>,
177 pub unlock_suggestions: Vec<String>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
181pub enum EvidenceAnalysisDepth {
182 None,
183 Mechanical,
184 BoundaryAware,
185 Structural,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
189pub struct EvidenceSignal {
190 pub id: String,
191 pub label: String,
192 pub present: bool,
193 pub detail: String,
194}
195
196#[derive(
197 Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
198)]
199pub enum EvidenceGrade {
200 None,
201 Compiled,
202 Tested,
203 Covered,
204 Hardened,
205 Proven,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
209pub enum CodebaseQualityGrade {
210 Excellent,
211 Good,
212 NeedsWork,
213 HighRisk,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
217pub enum TestCoverageSignal {
218 Present,
219 Sparse,
220 Unknown,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
224pub struct CapabilityGate {
225 pub id: String,
226 pub label: String,
227 pub available: bool,
228 pub command: String,
229 pub purpose: String,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
233pub struct AutopilotRun {
234 pub schema_version: String,
235 pub run_id: String,
236 pub root: String,
237 pub target: Option<String>,
238 pub mode: RefactorApplyMode,
239 pub status: AutopilotStatus,
240 pub budget_seconds: Option<u64>,
241 pub max_passes: usize,
242 pub max_candidates_per_pass: usize,
243 pub quality_before: CodebaseQualitySummary,
244 pub quality_after: Option<CodebaseQualitySummary>,
245 pub evidence: EvidenceSummary,
246 pub measured_evidence: Option<EvidenceArtifactRef>,
247 pub execution_summary: AutopilotExecutionSummary,
248 pub passes: Vec<AutopilotPass>,
249 pub total_planned_candidates: usize,
250 pub total_executed_candidates: usize,
251 pub total_skipped_candidates: usize,
252 pub budget_exhausted: bool,
253 pub note: String,
254 pub artifact_path: Option<String>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
258pub struct AutopilotExecutionSummary {
259 pub plans_created: usize,
260 pub executable_candidates_seen: usize,
261 pub validated_transactions: usize,
262 pub applied_transactions: usize,
263 pub blocked_or_plan_only_candidates: usize,
264 pub evidence_grade: EvidenceGrade,
265 pub analysis_depth: EvidenceAnalysisDepth,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
269pub struct AutopilotPass {
270 pub pass_index: usize,
271 pub plan_id: String,
272 pub plan_hash: String,
273 pub plan_artifact_path: Option<String>,
274 pub planned_candidates: usize,
275 pub executable_candidates: usize,
276 pub batch: Option<RefactorBatchApplyRun>,
277 pub status: AutopilotPassStatus,
278 pub note: String,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
282pub enum AutopilotStatus {
283 Reviewed,
284 Applied,
285 PartiallyApplied,
286 NoExecutableCandidates,
287 Rejected,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
291pub enum AutopilotPassStatus {
292 Planned,
293 Reviewed,
294 Applied,
295 PartiallyApplied,
296 NoExecutableCandidates,
297 Rejected,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
301pub struct SourceSnapshot {
302 pub file: String,
303 pub hash: String,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
307pub struct RefactorImpactSummary {
308 pub files_scanned: usize,
309 pub public_item_count: usize,
310 pub public_files: usize,
311 pub module_edge_count: usize,
312 pub patchable_hardening_changes: usize,
313 pub review_only_findings: usize,
314 pub oversized_files: usize,
315 pub oversized_functions: usize,
316 pub risk_level: RefactorRiskLevel,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
320pub enum RefactorRiskLevel {
321 Low,
322 Medium,
323 High,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327pub struct RefactorCandidate {
328 pub id: String,
329 pub candidate_hash: String,
330 pub recipe: RefactorRecipe,
331 pub title: String,
332 pub rationale: String,
333 pub file: String,
334 pub line: usize,
335 pub risk: RefactorRiskLevel,
336 pub status: RefactorCandidateStatus,
337 pub tier: RecipeTier,
338 pub required_evidence: EvidenceGrade,
339 pub evidence_satisfied: bool,
340 pub public_api_impact: bool,
341 pub apply_command: Option<String>,
342 pub required_gates: Vec<String>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
346pub enum RefactorCandidateStatus {
347 ApplyViaImprove,
348 PlanOnly,
349 NeedsHumanDesign,
350}
351
352#[derive(
353 Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
354)]
355pub enum RecipeTier {
356 Tier1,
357 Tier2,
358 Tier3,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
362pub enum RefactorRecipe {
363 BorrowParameterTightening,
364 ClonePressureReview,
365 ContextualErrorHardening,
366 ErrorContextPropagation,
367 ExtractFunctionCandidate,
368 IteratorCloned,
369 LenCheckIsEmpty,
370 LongFunctionReview,
371 MustUsePublicReturn,
372 RepeatedStringLiteralConst,
373 SecurityBoundaryReview,
374 SplitModuleCandidate,
375 BoundaryValidationReview,
376 PublicApiReview,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
380pub struct RefactorApplyRun {
381 pub schema_version: String,
382 pub root: String,
383 pub plan_path: String,
384 pub plan_id: String,
385 pub plan_hash: String,
386 pub candidate_id: String,
387 pub candidate_hash: Option<String>,
388 pub mode: RefactorApplyMode,
389 pub status: RefactorApplyStatus,
390 pub public_api_impact_allowed: bool,
391 pub stale_files: Vec<StaleSourceFile>,
392 pub hardening_run: Option<HardeningRun>,
393 pub note: String,
394 pub artifact_path: Option<String>,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
398pub struct RefactorBatchApplyRun {
399 pub schema_version: String,
400 pub root: String,
401 pub plan_path: String,
402 pub plan_id: String,
403 pub plan_hash: String,
404 pub mode: RefactorApplyMode,
405 pub status: RefactorBatchApplyStatus,
406 pub public_api_impact_allowed: bool,
407 pub max_candidates: usize,
408 pub requested_candidates: usize,
409 pub executed_candidates: usize,
410 pub skipped_candidates: usize,
411 pub steps: Vec<RefactorBatchCandidateRun>,
412 pub note: String,
413 pub artifact_path: Option<String>,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
417pub struct RefactorBatchCandidateRun {
418 pub candidate_id: String,
419 pub candidate_hash: Option<String>,
420 pub file: String,
421 pub status: RefactorApplyStatus,
422 pub stale_file: Option<StaleSourceFile>,
423 pub hardening_run: Option<HardeningRun>,
424 pub note: String,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
428pub enum RefactorApplyMode {
429 Review,
430 Apply,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
434pub enum RefactorBatchApplyStatus {
435 Reviewed,
436 Applied,
437 PartiallyApplied,
438 Rejected,
439 StalePlan,
440 NoExecutableCandidates,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
444pub enum RefactorApplyStatus {
445 Reviewed,
446 Applied,
447 Rejected,
448 StalePlan,
449 Unsupported,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
453pub struct StaleSourceFile {
454 pub file: String,
455 pub expected_hash: String,
456 pub actual_hash: String,
457}
458
459pub fn build_refactor_plan(
460 root: &Path,
461 artifact_root: Option<&Path>,
462 config: &RefactorPlanConfig,
463) -> anyhow::Result<RefactorPlan> {
464 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
465 let refactor = analyze_refactor(
466 &root,
467 RefactorAnalyzeConfig {
468 target: config.target.as_deref(),
469 max_files: config.max_files,
470 },
471 )?;
472 let measured_evidence = load_latest_evidence_for_root(artifact_root, &root)?;
473 let hardening = analyze_hardening(
474 &root,
475 HardeningAnalyzeConfig {
476 target: config.target.as_deref(),
477 max_files: config.max_files,
478 max_recipe_tier: measured_hardening_tier(measured_evidence.as_ref()),
479 evidence_depth: hardening_depth_for_evidence(measured_evidence.as_ref()),
480 },
481 )?;
482 let policy = load_project_policy(&root, config.policy_path.as_deref())?;
483 let workspace = workspace_summary(&root);
484 let behavior_spec = config
485 .behavior_spec_path
486 .as_ref()
487 .map(|path| path.display().to_string());
488 let capability_gates = capability_gates();
489 let evidence = summarize_evidence(
490 &workspace,
491 &refactor.files,
492 &capability_gates,
493 config.behavior_spec_path.is_some(),
494 measured_evidence.as_ref(),
495 );
496 let impact = summarize_impact(
497 &refactor.files,
498 refactor.module_edges.len(),
499 &hardening.findings,
500 hardening.changes.len(),
501 );
502 let mut candidates = Vec::new();
503 candidates.extend(hardening_candidates(&hardening.findings, config, &evidence));
504 candidates.extend(structural_candidates(&refactor.files, &evidence));
505 for candidate in &mut candidates {
506 candidate.candidate_hash = candidate_hash(candidate);
507 }
508 candidates.sort_by(|left, right| left.id.cmp(&right.id));
509 let source_snapshots = source_snapshots(&root, &refactor.files)?;
510
511 let required_gates = required_gates(config.behavior_spec_path.is_some());
512 let non_goals = vec![
513 "No broad API-changing refactors without explicit human allowance.".to_string(),
514 "No public API changes without explicit human review.".to_string(),
515 "No plan candidate may bypass improve/apply validation gates.".to_string(),
516 ];
517
518 let plan_id = plan_id(&root, config, &impact, &candidates);
519 let mut plan = RefactorPlan {
520 schema_version: "0.7".to_string(),
521 plan_id,
522 plan_hash: String::new(),
523 root: root.display().to_string(),
524 target: config
525 .target
526 .as_ref()
527 .map(|path| path.display().to_string()),
528 workspace,
529 policy,
530 behavior_spec,
531 evidence,
532 measured_evidence: measured_evidence.as_ref().map(EvidenceArtifactRef::from),
533 impact,
534 source_snapshots,
535 files: refactor.files,
536 module_edges: refactor.module_edges,
537 candidates,
538 required_gates,
539 non_goals,
540 artifact_path: None,
541 };
542 plan.plan_hash = refactor_plan_hash(&plan);
543
544 if let Some(artifact_root) = artifact_root {
545 let path = persist_refactor_plan(artifact_root, &plan)?;
546 plan.artifact_path = Some(path.display().to_string());
547 std::fs::write(&path, serde_json::to_string_pretty(&plan)?)?;
548 }
549
550 Ok(plan)
551}
552
553pub fn build_codebase_map(
554 root: &Path,
555 artifact_root: Option<&Path>,
556 config: &CodebaseMapConfig,
557) -> anyhow::Result<CodebaseMap> {
558 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
559 let refactor = analyze_refactor(
560 &root,
561 RefactorAnalyzeConfig {
562 target: config.target.as_deref(),
563 max_files: config.max_files,
564 },
565 )?;
566 let measured_evidence = load_latest_evidence_for_root(artifact_root, &root)?;
567 let hardening = analyze_hardening(
568 &root,
569 HardeningAnalyzeConfig {
570 target: config.target.as_deref(),
571 max_files: config.max_files,
572 max_recipe_tier: measured_hardening_tier(measured_evidence.as_ref()),
573 evidence_depth: hardening_depth_for_evidence(measured_evidence.as_ref()),
574 },
575 )?;
576 let policy = load_project_policy(&root, config.policy_path.as_deref())?;
577 let workspace = workspace_summary(&root);
578 let behavior_spec = config
579 .behavior_spec_path
580 .as_ref()
581 .map(|path| path.display().to_string());
582 let capability_gates = capability_gates();
583 let evidence = summarize_evidence(
584 &workspace,
585 &refactor.files,
586 &capability_gates,
587 config.behavior_spec_path.is_some(),
588 measured_evidence.as_ref(),
589 );
590 let impact = summarize_impact(
591 &refactor.files,
592 refactor.module_edges.len(),
593 &hardening.findings,
594 hardening.changes.len(),
595 );
596 let quality = summarize_quality(&refactor.files, &hardening.findings, &impact);
597 let recommended_actions = recommended_actions(&quality, &impact, &capability_gates, &evidence);
598 let map_id = codebase_map_id(&root, config, &quality, &impact);
599 let mut map = CodebaseMap {
600 schema_version: "0.7".to_string(),
601 map_id,
602 map_hash: String::new(),
603 root: root.display().to_string(),
604 target: config
605 .target
606 .as_ref()
607 .map(|path| path.display().to_string()),
608 workspace,
609 policy,
610 behavior_spec,
611 evidence,
612 measured_evidence: measured_evidence.as_ref().map(EvidenceArtifactRef::from),
613 quality,
614 capability_gates,
615 impact,
616 files: refactor.files,
617 module_edges: refactor.module_edges,
618 findings: hardening.findings,
619 recommended_actions,
620 artifact_path: None,
621 };
622 map.map_hash = codebase_map_hash(&map);
623
624 if let Some(artifact_root) = artifact_root {
625 let path = persist_codebase_map(artifact_root, &map)?;
626 map.artifact_path = Some(path.display().to_string());
627 std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
628 }
629
630 Ok(map)
631}
632
633pub fn run_autopilot(
634 root: &Path,
635 artifact_root: Option<&Path>,
636 config: &AutopilotConfig,
637) -> anyhow::Result<AutopilotRun> {
638 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
639 let map_config = CodebaseMapConfig {
640 target: config.target.clone(),
641 policy_path: config.policy_path.clone(),
642 behavior_spec_path: config.behavior_spec_path.clone(),
643 max_files: config.max_files,
644 };
645 let before_map = build_codebase_map(&root, artifact_root, &map_config)?;
646 let evidence = before_map.evidence.clone();
647 let quality_before = before_map.quality.clone();
648 let mode = if config.apply {
649 RefactorApplyMode::Apply
650 } else {
651 RefactorApplyMode::Review
652 };
653 let mut run = AutopilotRun {
654 schema_version: "0.7".to_string(),
655 run_id: autopilot_run_id(&root, config, &before_map),
656 root: root.display().to_string(),
657 target: config
658 .target
659 .as_ref()
660 .map(|path| path.display().to_string()),
661 mode,
662 status: AutopilotStatus::NoExecutableCandidates,
663 budget_seconds: config.budget.map(|duration| duration.as_secs()),
664 max_passes: config.max_passes,
665 max_candidates_per_pass: config.max_candidates,
666 quality_before,
667 quality_after: None,
668 evidence,
669 measured_evidence: before_map.measured_evidence.clone(),
670 execution_summary: AutopilotExecutionSummary {
671 plans_created: 0,
672 executable_candidates_seen: 0,
673 validated_transactions: 0,
674 applied_transactions: 0,
675 blocked_or_plan_only_candidates: 0,
676 evidence_grade: before_map.evidence.grade,
677 analysis_depth: before_map.evidence.analysis_depth.clone(),
678 },
679 passes: Vec::new(),
680 total_planned_candidates: 0,
681 total_executed_candidates: 0,
682 total_skipped_candidates: 0,
683 budget_exhausted: false,
684 note: String::new(),
685 artifact_path: None,
686 };
687
688 let started_at = std::time::Instant::now();
689 let pass_count = config.max_passes.max(1);
690 for pass_index in 1..=pass_count {
691 if config
692 .budget
693 .is_some_and(|budget| started_at.elapsed() >= budget)
694 {
695 run.budget_exhausted = true;
696 break;
697 }
698 let plan = build_refactor_plan(
699 &root,
700 artifact_root,
701 &RefactorPlanConfig {
702 target: config.target.clone(),
703 policy_path: config.policy_path.clone(),
704 behavior_spec_path: config.behavior_spec_path.clone(),
705 max_files: config.max_files,
706 },
707 )?;
708 let executable = count_executable_candidates(
709 &plan,
710 config.allow_public_api_impact,
711 config.max_candidates,
712 config.max_tier,
713 config.min_evidence,
714 );
715 run.total_planned_candidates += plan.candidates.len();
716
717 let mut pass = AutopilotPass {
718 pass_index,
719 plan_id: plan.plan_id.clone(),
720 plan_hash: plan.plan_hash.clone(),
721 plan_artifact_path: plan.artifact_path.clone(),
722 planned_candidates: plan.candidates.len(),
723 executable_candidates: executable,
724 batch: None,
725 status: AutopilotPassStatus::Planned,
726 note: String::new(),
727 };
728
729 if executable == 0 {
730 pass.status = AutopilotPassStatus::NoExecutableCandidates;
731 pass.note = "no executable low-risk candidates remain for this pass".to_string();
732 run.passes.push(pass);
733 break;
734 }
735
736 let Some(plan_path) = plan.artifact_path.as_ref() else {
737 pass.status = AutopilotPassStatus::Rejected;
738 pass.note = "autopilot requires persisted plan artifacts before execution".to_string();
739 run.passes.push(pass);
740 break;
741 };
742
743 let mut validation_timeout = config.validation_timeout;
744 if let Some(budget) = config.budget {
745 let Some(remaining) = budget.checked_sub(started_at.elapsed()) else {
746 run.budget_exhausted = true;
747 pass.status = AutopilotPassStatus::NoExecutableCandidates;
748 pass.note = "budget exhausted before execution could start".to_string();
749 run.passes.push(pass);
750 break;
751 };
752 if remaining.is_zero() {
753 run.budget_exhausted = true;
754 pass.status = AutopilotPassStatus::NoExecutableCandidates;
755 pass.note = "budget exhausted before execution could start".to_string();
756 run.passes.push(pass);
757 break;
758 }
759 validation_timeout = validation_timeout.min(remaining);
760 }
761
762 let batch = apply_refactor_plan_batch(
763 &root,
764 artifact_root,
765 &RefactorBatchApplyConfig {
766 plan_path: PathBuf::from(plan_path),
767 apply: config.apply,
768 allow_public_api_impact: config.allow_public_api_impact,
769 validation_timeout,
770 max_candidates: config.max_candidates,
771 max_tier: config.max_tier,
772 min_evidence: config.min_evidence,
773 },
774 )?;
775 if config
776 .budget
777 .is_some_and(|budget| started_at.elapsed() >= budget)
778 {
779 run.budget_exhausted = true;
780 }
781 run.total_executed_candidates += batch.executed_candidates;
782 run.total_skipped_candidates += batch.skipped_candidates;
783 pass.status = autopilot_pass_status(&batch.status);
784 pass.note = batch.note.clone();
785 let should_stop = !config.apply
786 || matches!(
787 batch.status,
788 RefactorBatchApplyStatus::Rejected
789 | RefactorBatchApplyStatus::StalePlan
790 | RefactorBatchApplyStatus::NoExecutableCandidates
791 | RefactorBatchApplyStatus::PartiallyApplied
792 )
793 || batch.executed_candidates == 0;
794 pass.batch = Some(batch);
795 run.passes.push(pass);
796 if should_stop {
797 break;
798 }
799 }
800
801 let after_map = if config.apply && run.total_executed_candidates > 0 {
802 Some(build_codebase_map(&root, artifact_root, &map_config)?)
803 } else {
804 None
805 };
806 run.quality_after = after_map.map(|map| map.quality);
807 run.status = autopilot_status(config.apply, &run.passes, run.total_executed_candidates);
808 run.note = autopilot_note(&run);
809 run.execution_summary = autopilot_execution_summary(&run);
810 persist_autopilot_run(artifact_root, run)
811}
812
813pub fn apply_refactor_plan_candidate(
814 root: &Path,
815 artifact_root: Option<&Path>,
816 config: &RefactorApplyConfig,
817) -> anyhow::Result<RefactorApplyRun> {
818 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
819 let plan_content = std::fs::read_to_string(&config.plan_path)?;
820 let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
821 let mode = if config.apply {
822 RefactorApplyMode::Apply
823 } else {
824 RefactorApplyMode::Review
825 };
826 let mut run = RefactorApplyRun {
827 schema_version: "0.7".to_string(),
828 root: root.display().to_string(),
829 plan_path: config.plan_path.display().to_string(),
830 plan_id: plan.plan_id.clone(),
831 plan_hash: plan.plan_hash.clone(),
832 candidate_id: config.candidate_id.clone(),
833 candidate_hash: None,
834 mode,
835 status: RefactorApplyStatus::Rejected,
836 public_api_impact_allowed: config.allow_public_api_impact,
837 stale_files: Vec::new(),
838 hardening_run: None,
839 note: String::new(),
840 artifact_path: None,
841 };
842
843 let actual_plan_hash = refactor_plan_hash(&plan);
844 if actual_plan_hash != plan.plan_hash {
845 run.status = RefactorApplyStatus::Rejected;
846 run.note = format!(
847 "plan hash mismatch: expected {} but recomputed {}",
848 plan.plan_hash, actual_plan_hash
849 );
850 return persist_apply_run(artifact_root, run);
851 }
852
853 let stale_files = stale_source_files(&root, &plan.source_snapshots)?;
854 if !stale_files.is_empty() {
855 run.status = RefactorApplyStatus::StalePlan;
856 run.stale_files = stale_files;
857 run.note =
858 "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
859 return persist_apply_run(artifact_root, run);
860 }
861
862 let Some(candidate) = plan
863 .candidates
864 .iter()
865 .find(|candidate| candidate.id == config.candidate_id)
866 else {
867 run.status = RefactorApplyStatus::Rejected;
868 run.note = "candidate id was not found in the refactor plan".to_string();
869 return persist_apply_run(artifact_root, run);
870 };
871 run.candidate_hash = Some(candidate.candidate_hash.clone());
872
873 let actual_candidate_hash = candidate_hash(candidate);
874 if actual_candidate_hash != candidate.candidate_hash {
875 run.status = RefactorApplyStatus::Rejected;
876 run.note = format!(
877 "candidate hash mismatch: expected {} but recomputed {}",
878 candidate.candidate_hash, actual_candidate_hash
879 );
880 return persist_apply_run(artifact_root, run);
881 }
882
883 if candidate.public_api_impact && !config.allow_public_api_impact {
884 run.status = RefactorApplyStatus::Rejected;
885 run.note = "candidate touches public API impact area; pass --allow-public-api-impact after human review".to_string();
886 return persist_apply_run(artifact_root, run);
887 }
888
889 if !candidate.evidence_satisfied {
890 run.status = RefactorApplyStatus::Unsupported;
891 run.note = format!(
892 "candidate requires {:?} evidence but plan evidence is {:?}",
893 candidate.required_evidence, plan.evidence.grade
894 );
895 return persist_apply_run(artifact_root, run);
896 }
897
898 if candidate.status != RefactorCandidateStatus::ApplyViaImprove
899 || !is_supported_mechanical_recipe(&candidate.recipe)
900 {
901 run.status = RefactorApplyStatus::Unsupported;
902 run.note = "candidate is plan-only; no executable recipe is available yet".to_string();
903 return persist_apply_run(artifact_root, run);
904 }
905
906 let hardening = run_hardening(
907 &root,
908 artifact_root,
909 &HardeningConfig {
910 target: Some(PathBuf::from(&candidate.file)),
911 policy_path: plan
912 .policy
913 .as_ref()
914 .map(|policy| PathBuf::from(policy.path.clone())),
915 behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
916 apply: config.apply,
917 max_files: 1,
918 max_recipe_tier: recipe_tier_number(candidate.tier),
919 evidence_depth: hardening_depth_for_grade(candidate.required_evidence),
920 validation_timeout: config.validation_timeout,
921 },
922 )?;
923
924 run.status = if config.apply {
925 if hardening.outcome.applied {
926 RefactorApplyStatus::Applied
927 } else {
928 RefactorApplyStatus::Rejected
929 }
930 } else if hardening.outcome.isolated_validation_passed {
931 RefactorApplyStatus::Reviewed
932 } else {
933 RefactorApplyStatus::Rejected
934 };
935 run.note = format!(
936 "executed candidate through hardening transaction; hardening status: {:?}",
937 hardening.outcome.status
938 );
939 run.hardening_run = Some(hardening);
940 persist_apply_run(artifact_root, run)
941}
942
943pub fn apply_refactor_plan_batch(
944 root: &Path,
945 artifact_root: Option<&Path>,
946 config: &RefactorBatchApplyConfig,
947) -> anyhow::Result<RefactorBatchApplyRun> {
948 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
949 let plan_content = std::fs::read_to_string(&config.plan_path)?;
950 let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
951 let mode = if config.apply {
952 RefactorApplyMode::Apply
953 } else {
954 RefactorApplyMode::Review
955 };
956 let mut run = RefactorBatchApplyRun {
957 schema_version: "0.7".to_string(),
958 root: root.display().to_string(),
959 plan_path: config.plan_path.display().to_string(),
960 plan_id: plan.plan_id.clone(),
961 plan_hash: plan.plan_hash.clone(),
962 mode,
963 status: RefactorBatchApplyStatus::Rejected,
964 public_api_impact_allowed: config.allow_public_api_impact,
965 max_candidates: config.max_candidates,
966 requested_candidates: 0,
967 executed_candidates: 0,
968 skipped_candidates: 0,
969 steps: Vec::new(),
970 note: String::new(),
971 artifact_path: None,
972 };
973
974 let actual_plan_hash = refactor_plan_hash(&plan);
975 if actual_plan_hash != plan.plan_hash {
976 run.status = RefactorBatchApplyStatus::Rejected;
977 run.note = format!(
978 "plan hash mismatch: expected {} but recomputed {}",
979 plan.plan_hash, actual_plan_hash
980 );
981 return persist_batch_apply_run(artifact_root, run);
982 }
983
984 let initial_stale_files = stale_source_files(&root, &plan.source_snapshots)?;
985 if !initial_stale_files.is_empty() {
986 run.status = RefactorBatchApplyStatus::StalePlan;
987 run.steps = initial_stale_files
988 .into_iter()
989 .map(|stale| RefactorBatchCandidateRun {
990 candidate_id: String::new(),
991 candidate_hash: None,
992 file: stale.file.clone(),
993 status: RefactorApplyStatus::StalePlan,
994 stale_file: Some(stale),
995 hardening_run: None,
996 note: "source snapshot no longer matches the workspace".to_string(),
997 })
998 .collect();
999 run.note =
1000 "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
1001 return persist_batch_apply_run(artifact_root, run);
1002 }
1003
1004 let queue = executable_candidate_queue(&plan, config);
1005 run.requested_candidates = queue.len();
1006 if queue.is_empty() {
1007 run.status = RefactorBatchApplyStatus::NoExecutableCandidates;
1008 run.note = "no executable low-risk candidates were available in the plan".to_string();
1009 return persist_batch_apply_run(artifact_root, run);
1010 }
1011
1012 for candidate in queue {
1013 let mut step = RefactorBatchCandidateRun {
1014 candidate_id: candidate.id.clone(),
1015 candidate_hash: Some(candidate.candidate_hash.clone()),
1016 file: candidate.file.clone(),
1017 status: RefactorApplyStatus::Rejected,
1018 stale_file: None,
1019 hardening_run: None,
1020 note: String::new(),
1021 };
1022
1023 let actual_candidate_hash = candidate_hash(candidate);
1024 if actual_candidate_hash != candidate.candidate_hash {
1025 step.note = format!(
1026 "candidate hash mismatch: expected {} but recomputed {}",
1027 candidate.candidate_hash, actual_candidate_hash
1028 );
1029 run.skipped_candidates += 1;
1030 run.steps.push(step);
1031 if config.apply {
1032 break;
1033 }
1034 continue;
1035 }
1036
1037 if let Some(stale) = stale_file_for_candidate(&root, &plan, &candidate.file)? {
1038 step.status = RefactorApplyStatus::StalePlan;
1039 step.stale_file = Some(stale);
1040 step.note =
1041 "candidate source file changed after planning; re-run mdx-rust plan".to_string();
1042 run.skipped_candidates += 1;
1043 run.steps.push(step);
1044 if config.apply {
1045 break;
1046 }
1047 continue;
1048 }
1049
1050 let hardening = run_hardening(
1051 &root,
1052 artifact_root,
1053 &HardeningConfig {
1054 target: Some(PathBuf::from(&candidate.file)),
1055 policy_path: plan
1056 .policy
1057 .as_ref()
1058 .map(|policy| PathBuf::from(policy.path.clone())),
1059 behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
1060 apply: config.apply,
1061 max_files: 1,
1062 max_recipe_tier: recipe_tier_number(candidate.tier),
1063 evidence_depth: hardening_depth_for_grade(candidate.required_evidence),
1064 validation_timeout: config.validation_timeout,
1065 },
1066 )?;
1067
1068 step.status = if config.apply {
1069 if hardening.outcome.applied {
1070 RefactorApplyStatus::Applied
1071 } else {
1072 RefactorApplyStatus::Rejected
1073 }
1074 } else if hardening.outcome.isolated_validation_passed {
1075 RefactorApplyStatus::Reviewed
1076 } else {
1077 RefactorApplyStatus::Rejected
1078 };
1079 step.note = format!(
1080 "executed candidate through hardening transaction; hardening status: {:?}",
1081 hardening.outcome.status
1082 );
1083 step.hardening_run = Some(hardening);
1084
1085 if matches!(
1086 step.status,
1087 RefactorApplyStatus::Reviewed | RefactorApplyStatus::Applied
1088 ) {
1089 run.executed_candidates += 1;
1090 } else {
1091 run.skipped_candidates += 1;
1092 }
1093
1094 let failed_apply_step = config.apply && step.status != RefactorApplyStatus::Applied;
1095 run.steps.push(step);
1096 if failed_apply_step {
1097 break;
1098 }
1099 }
1100
1101 run.status = batch_status(
1102 config.apply,
1103 run.executed_candidates,
1104 run.requested_candidates,
1105 );
1106 run.note = format!(
1107 "processed {} executable candidate(s); executed {}, skipped {}",
1108 run.requested_candidates, run.executed_candidates, run.skipped_candidates
1109 );
1110 persist_batch_apply_run(artifact_root, run)
1111}
1112
1113fn summarize_impact(
1114 files: &[RefactorFileSummary],
1115 module_edge_count: usize,
1116 findings: &[HardeningFinding],
1117 patchable_hardening_changes: usize,
1118) -> RefactorImpactSummary {
1119 let public_item_count = files.iter().map(|file| file.public_item_count).sum();
1120 let public_files = files
1121 .iter()
1122 .filter(|file| file.public_item_count > 0)
1123 .count();
1124 let oversized_files = files.iter().filter(|file| file.line_count >= 300).count();
1125 let oversized_functions = files
1126 .iter()
1127 .filter(|file| file.largest_function_lines >= 80)
1128 .count();
1129 let review_only_findings = findings.iter().filter(|finding| !finding.patchable).count();
1130 let risk_level = if public_item_count > 10 || oversized_files > 2 {
1131 RefactorRiskLevel::High
1132 } else if public_item_count > 0 || oversized_files > 0 || oversized_functions > 0 {
1133 RefactorRiskLevel::Medium
1134 } else {
1135 RefactorRiskLevel::Low
1136 };
1137
1138 RefactorImpactSummary {
1139 files_scanned: files.len(),
1140 public_item_count,
1141 public_files,
1142 module_edge_count,
1143 patchable_hardening_changes,
1144 review_only_findings,
1145 oversized_files,
1146 oversized_functions,
1147 risk_level,
1148 }
1149}
1150
1151fn summarize_quality(
1152 files: &[RefactorFileSummary],
1153 findings: &[HardeningFinding],
1154 impact: &RefactorImpactSummary,
1155) -> CodebaseQualitySummary {
1156 let patchable_findings = findings.iter().filter(|finding| finding.patchable).count();
1157 let review_only_findings = findings.len().saturating_sub(patchable_findings);
1158 let files_with_tests = files.iter().filter(|file| file.has_tests).count();
1159 let test_coverage_signal = if files.is_empty() {
1160 TestCoverageSignal::Unknown
1161 } else if files_with_tests > 0 {
1162 TestCoverageSignal::Present
1163 } else {
1164 TestCoverageSignal::Sparse
1165 };
1166
1167 let mut score = 0usize;
1168 score += patchable_findings.saturating_mul(8);
1169 score += review_only_findings.saturating_mul(4);
1170 score += impact.oversized_files.saturating_mul(10);
1171 score += impact.oversized_functions.saturating_mul(7);
1172 score += impact.public_files.saturating_mul(2);
1173 if test_coverage_signal == TestCoverageSignal::Sparse {
1174 score += 12;
1175 }
1176 let debt_score = score.min(100) as u8;
1177 let grade = if debt_score >= 70 {
1178 CodebaseQualityGrade::HighRisk
1179 } else if debt_score >= 35 {
1180 CodebaseQualityGrade::NeedsWork
1181 } else if debt_score >= 10 {
1182 CodebaseQualityGrade::Good
1183 } else {
1184 CodebaseQualityGrade::Excellent
1185 };
1186
1187 CodebaseQualitySummary {
1188 grade,
1189 debt_score,
1190 patchable_findings,
1191 review_only_findings,
1192 public_api_pressure: impact.public_item_count,
1193 oversized_files: impact.oversized_files,
1194 oversized_functions: impact.oversized_functions,
1195 test_coverage_signal,
1196 }
1197}
1198
1199fn summarize_evidence(
1200 workspace: &WorkspaceSummary,
1201 files: &[RefactorFileSummary],
1202 gates: &[CapabilityGate],
1203 has_behavior_spec: bool,
1204 measured: Option<&EvidenceRun>,
1205) -> EvidenceSummary {
1206 let has_tests = files.iter().any(|file| file.has_tests);
1207 let has_nextest = gates
1208 .iter()
1209 .any(|gate| gate.id == "nextest" && gate.available);
1210 let has_coverage_tool = gates
1211 .iter()
1212 .any(|gate| gate.id == "llvm-cov" && gate.available);
1213 let has_mutation_tool = gates
1214 .iter()
1215 .any(|gate| gate.id == "mutants" && gate.available);
1216
1217 let inferred_grade = if !workspace.cargo_metadata_available {
1218 EvidenceGrade::None
1219 } else if has_tests || has_behavior_spec || has_nextest {
1220 EvidenceGrade::Tested
1221 } else {
1222 EvidenceGrade::Compiled
1223 };
1224 let grade = measured.map(|run| run.grade).unwrap_or(inferred_grade);
1225 let max_autonomous_tier = max_tier_for_evidence(grade);
1226 let analysis_depth = measured
1227 .map(|run| run.analysis_depth.clone())
1228 .unwrap_or_else(|| analysis_depth_for_evidence(grade));
1229
1230 let mut signals = vec![
1231 EvidenceSignal {
1232 id: "cargo-metadata".to_string(),
1233 label: "Cargo metadata".to_string(),
1234 present: workspace.cargo_metadata_available,
1235 detail: if workspace.cargo_metadata_available {
1236 "workspace can be inspected and compile gates can run".to_string()
1237 } else {
1238 "no Cargo metadata was available for this target".to_string()
1239 },
1240 },
1241 EvidenceSignal {
1242 id: "tests-or-behavior-evals".to_string(),
1243 label: "Tests or behavior evals".to_string(),
1244 present: has_tests || has_behavior_spec,
1245 detail: if has_behavior_spec {
1246 "behavior eval spec was supplied".to_string()
1247 } else if has_tests {
1248 "at least one scanned file contains Rust test markers".to_string()
1249 } else {
1250 "no tests or behavior eval spec were detected for the scanned target".to_string()
1251 },
1252 },
1253 EvidenceSignal {
1254 id: "coverage-tool".to_string(),
1255 label: "Coverage tooling".to_string(),
1256 present: has_coverage_tool,
1257 detail: "cargo-llvm-cov availability is detected; run mdx-rust evidence --include-coverage to collect coverage evidence".to_string(),
1258 },
1259 EvidenceSignal {
1260 id: "mutation-tool".to_string(),
1261 label: "Mutation tooling".to_string(),
1262 present: has_mutation_tool,
1263 detail: "cargo-mutants availability is detected; run mdx-rust evidence --include-mutation to collect mutation evidence".to_string(),
1264 },
1265 ];
1266 if let Some(run) = measured {
1267 signals.push(EvidenceSignal {
1268 id: "measured-evidence".to_string(),
1269 label: "Measured evidence artifact".to_string(),
1270 present: true,
1271 detail: format!(
1272 "latest evidence run {} recorded {:?} evidence",
1273 run.run_id, run.grade
1274 ),
1275 });
1276 }
1277
1278 let mut unlock_suggestions = Vec::new();
1279 if grade == EvidenceGrade::None {
1280 unlock_suggestions.push(
1281 "Run mdx-rust from a Cargo workspace before allowing autonomous changes.".to_string(),
1282 );
1283 }
1284 if measured.is_none() && grade < EvidenceGrade::Tested {
1285 unlock_suggestions.push(
1286 "Add Rust tests or pass --eval-spec to unlock tested evidence for future recipes."
1287 .to_string(),
1288 );
1289 }
1290 if measured.is_none() {
1291 unlock_suggestions.push(
1292 "Run mdx-rust evidence to replace inferred evidence with measured test results."
1293 .to_string(),
1294 );
1295 }
1296 if !has_coverage_tool {
1297 unlock_suggestions
1298 .push("Install cargo-llvm-cov to prepare for covered Tier 2 recipe gates.".to_string());
1299 }
1300 if !has_mutation_tool {
1301 unlock_suggestions.push(
1302 "Install cargo-mutants to prepare for hardened Tier 2 and Tier 3 recipe gates."
1303 .to_string(),
1304 );
1305 }
1306
1307 EvidenceSummary {
1308 grade,
1309 max_autonomous_tier,
1310 analysis_depth,
1311 signals,
1312 unlocked_recipe_tiers: unlocked_recipe_tiers(grade),
1313 unlock_suggestions,
1314 }
1315}
1316
1317fn analysis_depth_for_evidence(grade: EvidenceGrade) -> EvidenceAnalysisDepth {
1318 match grade {
1319 EvidenceGrade::None => EvidenceAnalysisDepth::None,
1320 EvidenceGrade::Compiled => EvidenceAnalysisDepth::Mechanical,
1321 EvidenceGrade::Tested => EvidenceAnalysisDepth::BoundaryAware,
1322 EvidenceGrade::Covered | EvidenceGrade::Hardened | EvidenceGrade::Proven => {
1323 EvidenceAnalysisDepth::Structural
1324 }
1325 }
1326}
1327
1328fn unlocked_recipe_tiers(grade: EvidenceGrade) -> Vec<String> {
1329 let mut tiers = Vec::new();
1330 if grade >= EvidenceGrade::Compiled {
1331 tiers.push("Tier 1 executable mechanical recipes".to_string());
1332 }
1333 if grade >= EvidenceGrade::Tested {
1334 tiers.push("Tier 2 boundary review candidates".to_string());
1335 }
1336 if grade >= EvidenceGrade::Covered {
1337 tiers.push("Tier 2 structural mechanical recipes".to_string());
1338 }
1339 if grade >= EvidenceGrade::Hardened {
1340 tiers.push("Tier 3 semantic candidates in review".to_string());
1341 }
1342 tiers
1343}
1344
1345fn max_tier_for_evidence(grade: EvidenceGrade) -> u8 {
1346 match grade {
1347 EvidenceGrade::None => 0,
1348 EvidenceGrade::Compiled | EvidenceGrade::Tested => 1,
1349 EvidenceGrade::Covered => 2,
1350 EvidenceGrade::Hardened | EvidenceGrade::Proven => 3,
1351 }
1352}
1353
1354fn measured_hardening_tier(measured: Option<&EvidenceRun>) -> u8 {
1355 match measured.map(|run| run.grade) {
1356 Some(EvidenceGrade::Hardened | EvidenceGrade::Proven) => 3,
1357 Some(EvidenceGrade::Covered) => 2,
1358 _ => 1,
1359 }
1360}
1361
1362fn hardening_depth_for_evidence(measured: Option<&EvidenceRun>) -> HardeningEvidenceDepth {
1363 match measured.map(|run| run.grade) {
1364 Some(EvidenceGrade::Proven) => HardeningEvidenceDepth::Proven,
1365 Some(EvidenceGrade::Hardened) => HardeningEvidenceDepth::Hardened,
1366 Some(EvidenceGrade::Covered) => HardeningEvidenceDepth::Covered,
1367 Some(EvidenceGrade::Tested) => HardeningEvidenceDepth::Tested,
1368 _ => HardeningEvidenceDepth::Basic,
1369 }
1370}
1371
1372fn hardening_depth_for_grade(grade: EvidenceGrade) -> HardeningEvidenceDepth {
1373 match grade {
1374 EvidenceGrade::Proven => HardeningEvidenceDepth::Proven,
1375 EvidenceGrade::Hardened => HardeningEvidenceDepth::Hardened,
1376 EvidenceGrade::Covered => HardeningEvidenceDepth::Covered,
1377 EvidenceGrade::Tested => HardeningEvidenceDepth::Tested,
1378 EvidenceGrade::None | EvidenceGrade::Compiled => HardeningEvidenceDepth::Basic,
1379 }
1380}
1381
1382fn capability_gates() -> Vec<CapabilityGate> {
1383 vec![
1384 CapabilityGate {
1385 id: "nextest".to_string(),
1386 label: "cargo-nextest".to_string(),
1387 available: cargo_subcommand_exists("nextest"),
1388 command: "cargo nextest run".to_string(),
1389 purpose: "fast, isolated Rust test execution for behavior gates".to_string(),
1390 },
1391 CapabilityGate {
1392 id: "llvm-cov".to_string(),
1393 label: "cargo-llvm-cov".to_string(),
1394 available: cargo_subcommand_exists("llvm-cov"),
1395 command: "cargo llvm-cov".to_string(),
1396 purpose: "coverage evidence before broad autonomous refactoring".to_string(),
1397 },
1398 CapabilityGate {
1399 id: "mutants".to_string(),
1400 label: "cargo-mutants".to_string(),
1401 available: cargo_subcommand_exists("mutants"),
1402 command: "cargo mutants".to_string(),
1403 purpose: "mutation testing signal for high-value refactor targets".to_string(),
1404 },
1405 CapabilityGate {
1406 id: "semver-checks".to_string(),
1407 label: "cargo-semver-checks".to_string(),
1408 available: cargo_subcommand_exists("semver-checks"),
1409 command: "cargo semver-checks".to_string(),
1410 purpose: "public API compatibility gate for library refactors".to_string(),
1411 },
1412 ]
1413}
1414
1415fn recommended_actions(
1416 quality: &CodebaseQualitySummary,
1417 impact: &RefactorImpactSummary,
1418 gates: &[CapabilityGate],
1419 evidence: &EvidenceSummary,
1420) -> Vec<String> {
1421 let mut actions = Vec::new();
1422 if quality.patchable_findings > 0 && evidence.grade >= EvidenceGrade::Compiled {
1423 actions.push(
1424 "Run mdx-rust autopilot --apply to execute low-risk Tier 1 mechanical hardening passes."
1425 .to_string(),
1426 );
1427 } else if quality.patchable_findings > 0 {
1428 actions.push(
1429 "Autonomous execution is blocked until this target has at least compiled evidence."
1430 .to_string(),
1431 );
1432 }
1433 if quality.review_only_findings > 0 {
1434 actions.push(
1435 "Review security-sensitive findings before enabling broader recipes.".to_string(),
1436 );
1437 }
1438 if impact.oversized_files > 0 || impact.oversized_functions > 0 {
1439 actions.push(
1440 "Use mdx-rust plan to stage larger module and function refactors behind behavior gates."
1441 .to_string(),
1442 );
1443 }
1444 if quality.public_api_pressure > 0
1445 && gates
1446 .iter()
1447 .any(|gate| gate.id == "semver-checks" && !gate.available)
1448 {
1449 actions.push(
1450 "Install cargo-semver-checks before allowing public API impacting refactors."
1451 .to_string(),
1452 );
1453 }
1454 if quality.test_coverage_signal == TestCoverageSignal::Sparse {
1455 actions.push(
1456 "Add a behavior eval spec or stronger Rust tests before broad autonomous apply."
1457 .to_string(),
1458 );
1459 }
1460 actions.extend(evidence.unlock_suggestions.iter().cloned());
1461 if actions.is_empty() {
1462 actions.push(
1463 "No immediate autonomous changes found. Keep policy and behavior gates current."
1464 .to_string(),
1465 );
1466 }
1467 actions
1468}
1469
1470fn cargo_subcommand_exists(name: &str) -> bool {
1471 let command = format!("cargo-{name}");
1472 let Some(path_var) = std::env::var_os("PATH") else {
1473 return false;
1474 };
1475 std::env::split_paths(&path_var).any(|dir| dir.join(&command).is_file())
1476}
1477
1478fn hardening_candidates(
1479 findings: &[HardeningFinding],
1480 config: &RefactorPlanConfig,
1481 evidence: &EvidenceSummary,
1482) -> Vec<RefactorCandidate> {
1483 findings
1484 .iter()
1485 .filter_map(|finding| {
1486 let file = finding.file.display().to_string();
1487 let required_evidence = required_evidence_for_hardening_strategy(&finding.strategy);
1488 let evidence_satisfied = evidence.grade >= required_evidence;
1489 let recipe = recipe_for_hardening_strategy(&finding.strategy);
1490 if !finding.patchable && !evidence_satisfied {
1491 return None;
1492 }
1493
1494 Some(RefactorCandidate {
1495 id: format!(
1496 "plan-hardening-{}-{}-{}",
1497 sanitize_id(&file),
1498 sanitize_id(&format!("{:?}", finding.strategy)),
1499 finding.line
1500 ),
1501 candidate_hash: String::new(),
1502 recipe,
1503 title: finding.title.clone(),
1504 rationale: if finding.patchable {
1505 if required_evidence >= EvidenceGrade::Covered {
1506 "Patchable Tier 2 structural mechanical refactor can be applied only when measured coverage evidence unlocks it.".to_string()
1507 } else {
1508 "Patchable Tier 1 mechanical hardening can be applied through the existing isolated validation transaction.".to_string()
1509 }
1510 } else {
1511 "Higher-evidence review candidate surfaced from security or boundary analysis; it remains plan-only until a safe executable recipe exists.".to_string()
1512 },
1513 file: file.clone(),
1514 line: finding.line,
1515 risk: risk_for_hardening_strategy(&finding.strategy),
1516 status: if evidence_satisfied {
1517 if finding.patchable {
1518 RefactorCandidateStatus::ApplyViaImprove
1519 } else {
1520 RefactorCandidateStatus::PlanOnly
1521 }
1522 } else {
1523 RefactorCandidateStatus::PlanOnly
1524 },
1525 tier: if required_evidence >= EvidenceGrade::Hardened {
1526 RecipeTier::Tier3
1527 } else if required_evidence >= EvidenceGrade::Covered {
1528 RecipeTier::Tier2
1529 } else if finding.patchable {
1530 RecipeTier::Tier1
1531 } else {
1532 RecipeTier::Tier2
1533 },
1534 required_evidence,
1535 evidence_satisfied,
1536 public_api_impact: false,
1537 apply_command: (finding.patchable && evidence_satisfied)
1538 .then(|| apply_command(&file, config, required_evidence)),
1539 required_gates: if finding.patchable {
1540 required_gates(config.behavior_spec_path.is_some())
1541 } else {
1542 vec![
1543 "human review of boundary contract".to_string(),
1544 "behavior evals or tests must cover the boundary".to_string(),
1545 "future executable recipe must route through hardening transactions"
1546 .to_string(),
1547 ]
1548 },
1549 })
1550 })
1551 .collect()
1552}
1553
1554fn required_evidence_for_hardening_strategy(
1555 strategy: &mdx_rust_analysis::HardeningStrategy,
1556) -> EvidenceGrade {
1557 match strategy {
1558 mdx_rust_analysis::HardeningStrategy::LenCheckIsEmpty
1559 | mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => {
1560 EvidenceGrade::Covered
1561 }
1562 mdx_rust_analysis::HardeningStrategy::ClonePressureReview
1563 | mdx_rust_analysis::HardeningStrategy::LongFunctionReview => EvidenceGrade::Hardened,
1564 mdx_rust_analysis::HardeningStrategy::EnvAccessReview
1565 | mdx_rust_analysis::HardeningStrategy::FileIoReview
1566 | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview
1567 | mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
1568 | mdx_rust_analysis::HardeningStrategy::UnsafeReview => EvidenceGrade::Tested,
1569 _ => EvidenceGrade::Compiled,
1570 }
1571}
1572
1573fn recipe_for_hardening_strategy(
1574 strategy: &mdx_rust_analysis::HardeningStrategy,
1575) -> RefactorRecipe {
1576 match strategy {
1577 mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening => {
1578 RefactorRecipe::BorrowParameterTightening
1579 }
1580 mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation => {
1581 RefactorRecipe::ErrorContextPropagation
1582 }
1583 mdx_rust_analysis::HardeningStrategy::IteratorCloned => RefactorRecipe::IteratorCloned,
1584 mdx_rust_analysis::HardeningStrategy::LenCheckIsEmpty => RefactorRecipe::LenCheckIsEmpty,
1585 mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn => {
1586 RefactorRecipe::MustUsePublicReturn
1587 }
1588 mdx_rust_analysis::HardeningStrategy::ClonePressureReview => {
1589 RefactorRecipe::ClonePressureReview
1590 }
1591 mdx_rust_analysis::HardeningStrategy::LongFunctionReview => {
1592 RefactorRecipe::LongFunctionReview
1593 }
1594 mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => {
1595 RefactorRecipe::RepeatedStringLiteralConst
1596 }
1597 mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => {
1598 RefactorRecipe::BoundaryValidationReview
1599 }
1600 mdx_rust_analysis::HardeningStrategy::EnvAccessReview
1601 | mdx_rust_analysis::HardeningStrategy::FileIoReview => {
1602 RefactorRecipe::BoundaryValidationReview
1603 }
1604 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
1605 | mdx_rust_analysis::HardeningStrategy::UnsafeReview => {
1606 RefactorRecipe::SecurityBoundaryReview
1607 }
1608 _ => RefactorRecipe::ContextualErrorHardening,
1609 }
1610}
1611
1612fn risk_for_hardening_strategy(
1613 strategy: &mdx_rust_analysis::HardeningStrategy,
1614) -> RefactorRiskLevel {
1615 match strategy {
1616 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
1617 | mdx_rust_analysis::HardeningStrategy::UnsafeReview => RefactorRiskLevel::High,
1618 mdx_rust_analysis::HardeningStrategy::EnvAccessReview
1619 | mdx_rust_analysis::HardeningStrategy::FileIoReview
1620 | mdx_rust_analysis::HardeningStrategy::ClonePressureReview
1621 | mdx_rust_analysis::HardeningStrategy::LongFunctionReview
1622 | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => RefactorRiskLevel::Medium,
1623 _ => RefactorRiskLevel::Low,
1624 }
1625}
1626
1627fn structural_candidates(
1628 files: &[RefactorFileSummary],
1629 evidence: &EvidenceSummary,
1630) -> Vec<RefactorCandidate> {
1631 let mut candidates = Vec::new();
1632 let split_threshold = if evidence.grade >= EvidenceGrade::Hardened {
1633 220
1634 } else {
1635 300
1636 };
1637 let extract_threshold = if evidence.grade >= EvidenceGrade::Hardened {
1638 50
1639 } else {
1640 80
1641 };
1642 for file in files {
1643 let file_path = file.file.display().to_string();
1644 if file.line_count >= split_threshold {
1645 let required_evidence = EvidenceGrade::Covered;
1646 candidates.push(RefactorCandidate {
1647 id: format!("plan-split-module-{}", sanitize_id(&file_path)),
1648 candidate_hash: String::new(),
1649 recipe: RefactorRecipe::SplitModuleCandidate,
1650 title: "Split oversized module".to_string(),
1651 rationale: format!(
1652 "{} has {} lines. Current evidence threshold is {split_threshold} lines for split-module planning.",
1653 file_path, file.line_count
1654 ),
1655 file: file_path.clone(),
1656 line: 1,
1657 risk: if file.public_item_count > 0 {
1658 RefactorRiskLevel::High
1659 } else {
1660 RefactorRiskLevel::Medium
1661 },
1662 status: RefactorCandidateStatus::NeedsHumanDesign,
1663 tier: RecipeTier::Tier2,
1664 required_evidence,
1665 evidence_satisfied: evidence.grade >= required_evidence,
1666 public_api_impact: file.public_item_count > 0,
1667 apply_command: None,
1668 required_gates: vec![
1669 "human design review".to_string(),
1670 "cargo check".to_string(),
1671 "cargo clippy -- -D warnings".to_string(),
1672 "behavior evals when configured".to_string(),
1673 ],
1674 });
1675 }
1676
1677 if file.largest_function_lines >= extract_threshold {
1678 let required_evidence = EvidenceGrade::Covered;
1679 candidates.push(RefactorCandidate {
1680 id: format!("plan-extract-function-{}", sanitize_id(&file_path)),
1681 candidate_hash: String::new(),
1682 recipe: RefactorRecipe::ExtractFunctionCandidate,
1683 title: "Extract long function".to_string(),
1684 rationale: format!(
1685 "Largest function in {} is {} lines. Current evidence threshold is {extract_threshold} lines for extract-function planning.",
1686 file_path, file.largest_function_lines
1687 ),
1688 file: file_path.clone(),
1689 line: 1,
1690 risk: RefactorRiskLevel::Medium,
1691 status: RefactorCandidateStatus::PlanOnly,
1692 tier: RecipeTier::Tier2,
1693 required_evidence,
1694 evidence_satisfied: evidence.grade >= required_evidence,
1695 public_api_impact: file.public_item_count > 0,
1696 apply_command: None,
1697 required_gates: vec![
1698 "targeted tests or behavior evals".to_string(),
1699 "cargo check".to_string(),
1700 "cargo clippy -- -D warnings".to_string(),
1701 ],
1702 });
1703 }
1704
1705 if file.public_item_count > 0 {
1706 let required_evidence = EvidenceGrade::Tested;
1707 candidates.push(RefactorCandidate {
1708 id: format!("plan-public-api-{}", sanitize_id(&file_path)),
1709 candidate_hash: String::new(),
1710 recipe: RefactorRecipe::PublicApiReview,
1711 title: "Protect public API before refactoring".to_string(),
1712 rationale: format!(
1713 "{} exposes {} public item(s). Treat signature changes as semver-impacting.",
1714 file_path, file.public_item_count
1715 ),
1716 file: file_path,
1717 line: 1,
1718 risk: RefactorRiskLevel::Medium,
1719 status: RefactorCandidateStatus::PlanOnly,
1720 tier: RecipeTier::Tier1,
1721 required_evidence,
1722 evidence_satisfied: evidence.grade >= required_evidence,
1723 public_api_impact: true,
1724 apply_command: None,
1725 required_gates: vec![
1726 "public API review".to_string(),
1727 "docs and changelog review for exported changes".to_string(),
1728 ],
1729 });
1730 }
1731 }
1732
1733 candidates
1734}
1735
1736fn required_gates(has_behavior_spec: bool) -> Vec<String> {
1737 let mut gates = vec![
1738 "cargo check".to_string(),
1739 "cargo clippy -- -D warnings".to_string(),
1740 "review plan artifact before applying".to_string(),
1741 ];
1742 if has_behavior_spec {
1743 gates.push("behavior eval spec must pass in isolation and after apply".to_string());
1744 }
1745 gates
1746}
1747
1748fn apply_command(file: &str, config: &RefactorPlanConfig, evidence: EvidenceGrade) -> String {
1749 let mut command = format!("mdx-rust improve {} --apply", shell_word_str(file));
1750 if evidence >= EvidenceGrade::Covered {
1751 command.push_str(" --tier 2");
1752 }
1753 if let Some(policy) = &config.policy_path {
1754 command.push_str(&format!(" --policy {}", shell_word_path(policy)));
1755 }
1756 if let Some(eval_spec) = &config.behavior_spec_path {
1757 command.push_str(&format!(" --eval-spec {}", shell_word_path(eval_spec)));
1758 }
1759 command
1760}
1761
1762fn shell_word_path(path: &Path) -> String {
1763 shell_word_str(&path.display().to_string())
1764}
1765
1766fn shell_word_str(value: &str) -> String {
1767 if value
1768 .chars()
1769 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
1770 {
1771 value.to_string()
1772 } else {
1773 format!("'{}'", value.replace('\'', "'\\''"))
1774 }
1775}
1776
1777fn plan_id(
1778 root: &Path,
1779 config: &RefactorPlanConfig,
1780 impact: &RefactorImpactSummary,
1781 candidates: &[RefactorCandidate],
1782) -> String {
1783 let mut bytes = Vec::new();
1784 bytes.extend_from_slice(root.display().to_string().as_bytes());
1785 bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
1786 bytes.extend_from_slice(format!("{:?}", config.policy_path).as_bytes());
1787 bytes.extend_from_slice(format!("{:?}", config.behavior_spec_path).as_bytes());
1788 bytes.extend_from_slice(format!("{impact:?}").as_bytes());
1789 bytes.extend_from_slice(format!("{candidates:?}").as_bytes());
1790 stable_hash_hex(&bytes)
1791}
1792
1793fn codebase_map_id(
1794 root: &Path,
1795 config: &CodebaseMapConfig,
1796 quality: &CodebaseQualitySummary,
1797 impact: &RefactorImpactSummary,
1798) -> String {
1799 let mut bytes = Vec::new();
1800 bytes.extend_from_slice(root.display().to_string().as_bytes());
1801 bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
1802 bytes.extend_from_slice(format!("{quality:?}").as_bytes());
1803 bytes.extend_from_slice(format!("{impact:?}").as_bytes());
1804 stable_hash_hex(&bytes)
1805}
1806
1807fn codebase_map_hash(map: &CodebaseMap) -> String {
1808 let mut bytes = Vec::new();
1809 bytes.extend_from_slice(map.schema_version.as_bytes());
1810 bytes.extend_from_slice(map.map_id.as_bytes());
1811 bytes.extend_from_slice(map.root.as_bytes());
1812 bytes.extend_from_slice(format!("{:?}", map.target).as_bytes());
1813 bytes.extend_from_slice(format!("{:?}", map.quality).as_bytes());
1814 bytes.extend_from_slice(format!("{:?}", map.evidence).as_bytes());
1815 bytes.extend_from_slice(format!("{:?}", map.measured_evidence).as_bytes());
1816 bytes.extend_from_slice(format!("{:?}", map.impact).as_bytes());
1817 bytes.extend_from_slice(format!("{:?}", map.files).as_bytes());
1818 bytes.extend_from_slice(format!("{:?}", map.module_edges).as_bytes());
1819 bytes.extend_from_slice(format!("{:?}", map.findings).as_bytes());
1820 stable_hash_hex(&bytes)
1821}
1822
1823fn autopilot_run_id(root: &Path, config: &AutopilotConfig, map: &CodebaseMap) -> String {
1824 let mut bytes = Vec::new();
1825 bytes.extend_from_slice(root.display().to_string().as_bytes());
1826 bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
1827 bytes.extend_from_slice(config.apply.to_string().as_bytes());
1828 bytes.extend_from_slice(config.max_passes.to_string().as_bytes());
1829 bytes.extend_from_slice(config.max_candidates.to_string().as_bytes());
1830 bytes.extend_from_slice(format!("{:?}", config.max_tier).as_bytes());
1831 bytes.extend_from_slice(format!("{:?}", config.min_evidence).as_bytes());
1832 bytes.extend_from_slice(map.map_hash.as_bytes());
1833 stable_hash_hex(&bytes)
1834}
1835
1836fn refactor_plan_hash(plan: &RefactorPlan) -> String {
1837 let mut bytes = Vec::new();
1838 bytes.extend_from_slice(plan.schema_version.as_bytes());
1839 bytes.extend_from_slice(plan.plan_id.as_bytes());
1840 bytes.extend_from_slice(plan.root.as_bytes());
1841 bytes.extend_from_slice(format!("{:?}", plan.target).as_bytes());
1842 bytes.extend_from_slice(format!("{:?}", plan.evidence).as_bytes());
1843 bytes.extend_from_slice(format!("{:?}", plan.measured_evidence).as_bytes());
1844 bytes.extend_from_slice(format!("{:?}", plan.impact).as_bytes());
1845 bytes.extend_from_slice(format!("{:?}", plan.source_snapshots).as_bytes());
1846 bytes.extend_from_slice(format!("{:?}", plan.module_edges).as_bytes());
1847 bytes.extend_from_slice(format!("{:?}", plan.candidates).as_bytes());
1848 stable_hash_hex(&bytes)
1849}
1850
1851fn candidate_hash(candidate: &RefactorCandidate) -> String {
1852 let mut bytes = Vec::new();
1853 bytes.extend_from_slice(candidate.id.as_bytes());
1854 bytes.extend_from_slice(format!("{:?}", candidate.recipe).as_bytes());
1855 bytes.extend_from_slice(candidate.title.as_bytes());
1856 bytes.extend_from_slice(candidate.rationale.as_bytes());
1857 bytes.extend_from_slice(candidate.file.as_bytes());
1858 bytes.extend_from_slice(candidate.line.to_string().as_bytes());
1859 bytes.extend_from_slice(format!("{:?}", candidate.risk).as_bytes());
1860 bytes.extend_from_slice(format!("{:?}", candidate.status).as_bytes());
1861 bytes.extend_from_slice(format!("{:?}", candidate.tier).as_bytes());
1862 bytes.extend_from_slice(format!("{:?}", candidate.required_evidence).as_bytes());
1863 bytes.extend_from_slice(candidate.evidence_satisfied.to_string().as_bytes());
1864 bytes.extend_from_slice(candidate.public_api_impact.to_string().as_bytes());
1865 bytes.extend_from_slice(format!("{:?}", candidate.apply_command).as_bytes());
1866 stable_hash_hex(&bytes)
1867}
1868
1869fn source_snapshots(
1870 root: &Path,
1871 files: &[RefactorFileSummary],
1872) -> anyhow::Result<Vec<SourceSnapshot>> {
1873 let mut snapshots = Vec::new();
1874 for file in files {
1875 let content = std::fs::read(root.join(&file.file))?;
1876 snapshots.push(SourceSnapshot {
1877 file: file.file.display().to_string(),
1878 hash: stable_hash_hex(&content),
1879 });
1880 }
1881 Ok(snapshots)
1882}
1883
1884fn stale_source_files(
1885 root: &Path,
1886 snapshots: &[SourceSnapshot],
1887) -> anyhow::Result<Vec<StaleSourceFile>> {
1888 let mut stale = Vec::new();
1889 for snapshot in snapshots {
1890 let rel = safe_relative_path(&snapshot.file)?;
1891 let actual_hash = std::fs::read(root.join(&rel))
1892 .map(|content| stable_hash_hex(&content))
1893 .unwrap_or_else(|_| "<missing>".to_string());
1894 if actual_hash != snapshot.hash {
1895 stale.push(StaleSourceFile {
1896 file: snapshot.file.clone(),
1897 expected_hash: snapshot.hash.clone(),
1898 actual_hash,
1899 });
1900 }
1901 }
1902 Ok(stale)
1903}
1904
1905fn stale_file_for_candidate(
1906 root: &Path,
1907 plan: &RefactorPlan,
1908 file: &str,
1909) -> anyhow::Result<Option<StaleSourceFile>> {
1910 let Some(snapshot) = plan
1911 .source_snapshots
1912 .iter()
1913 .find(|snapshot| snapshot.file == file)
1914 else {
1915 return Ok(Some(StaleSourceFile {
1916 file: file.to_string(),
1917 expected_hash: "<missing-snapshot>".to_string(),
1918 actual_hash: "<unknown>".to_string(),
1919 }));
1920 };
1921 let rel = safe_relative_path(&snapshot.file)?;
1922 let actual_hash = std::fs::read(root.join(&rel))
1923 .map(|content| stable_hash_hex(&content))
1924 .unwrap_or_else(|_| "<missing>".to_string());
1925 if actual_hash == snapshot.hash {
1926 Ok(None)
1927 } else {
1928 Ok(Some(StaleSourceFile {
1929 file: snapshot.file.clone(),
1930 expected_hash: snapshot.hash.clone(),
1931 actual_hash,
1932 }))
1933 }
1934}
1935
1936fn executable_candidate_queue<'a>(
1937 plan: &'a RefactorPlan,
1938 config: &RefactorBatchApplyConfig,
1939) -> Vec<&'a RefactorCandidate> {
1940 let mut queue = Vec::new();
1941 let mut seen_files = std::collections::BTreeSet::new();
1942 for candidate in &plan.candidates {
1943 if queue.len() >= config.max_candidates {
1944 break;
1945 }
1946 if candidate.status != RefactorCandidateStatus::ApplyViaImprove
1947 || !is_supported_mechanical_recipe(&candidate.recipe)
1948 {
1949 continue;
1950 }
1951 if !candidate.evidence_satisfied
1952 || candidate.required_evidence > plan.evidence.grade
1953 || plan.evidence.grade < config.min_evidence
1954 || candidate.tier > config.max_tier
1955 {
1956 continue;
1957 }
1958 if candidate.public_api_impact && !config.allow_public_api_impact {
1959 continue;
1960 }
1961 if seen_files.insert(candidate.file.clone()) {
1962 queue.push(candidate);
1963 }
1964 }
1965 queue
1966}
1967
1968fn is_supported_mechanical_recipe(recipe: &RefactorRecipe) -> bool {
1969 matches!(
1970 recipe,
1971 RefactorRecipe::BorrowParameterTightening
1972 | RefactorRecipe::ContextualErrorHardening
1973 | RefactorRecipe::ErrorContextPropagation
1974 | RefactorRecipe::IteratorCloned
1975 | RefactorRecipe::LenCheckIsEmpty
1976 | RefactorRecipe::MustUsePublicReturn
1977 | RefactorRecipe::RepeatedStringLiteralConst
1978 )
1979}
1980
1981fn count_executable_candidates(
1982 plan: &RefactorPlan,
1983 allow_public_api_impact: bool,
1984 max_candidates: usize,
1985 max_tier: RecipeTier,
1986 min_evidence: EvidenceGrade,
1987) -> usize {
1988 executable_candidate_queue(
1989 plan,
1990 &RefactorBatchApplyConfig {
1991 plan_path: PathBuf::new(),
1992 apply: false,
1993 allow_public_api_impact,
1994 validation_timeout: Duration::from_secs(1),
1995 max_candidates,
1996 max_tier,
1997 min_evidence,
1998 },
1999 )
2000 .len()
2001}
2002
2003fn recipe_tier_number(tier: RecipeTier) -> u8 {
2004 match tier {
2005 RecipeTier::Tier1 => 1,
2006 RecipeTier::Tier2 => 2,
2007 RecipeTier::Tier3 => 3,
2008 }
2009}
2010
2011fn autopilot_pass_status(status: &RefactorBatchApplyStatus) -> AutopilotPassStatus {
2012 match status {
2013 RefactorBatchApplyStatus::Reviewed => AutopilotPassStatus::Reviewed,
2014 RefactorBatchApplyStatus::Applied => AutopilotPassStatus::Applied,
2015 RefactorBatchApplyStatus::PartiallyApplied => AutopilotPassStatus::PartiallyApplied,
2016 RefactorBatchApplyStatus::NoExecutableCandidates => {
2017 AutopilotPassStatus::NoExecutableCandidates
2018 }
2019 RefactorBatchApplyStatus::Rejected | RefactorBatchApplyStatus::StalePlan => {
2020 AutopilotPassStatus::Rejected
2021 }
2022 }
2023}
2024
2025fn autopilot_status(
2026 apply: bool,
2027 passes: &[AutopilotPass],
2028 executed_candidates: usize,
2029) -> AutopilotStatus {
2030 if executed_candidates == 0 {
2031 if passes
2032 .iter()
2033 .any(|pass| pass.status == AutopilotPassStatus::Rejected)
2034 {
2035 AutopilotStatus::Rejected
2036 } else {
2037 AutopilotStatus::NoExecutableCandidates
2038 }
2039 } else if !apply {
2040 AutopilotStatus::Reviewed
2041 } else if passes
2042 .iter()
2043 .any(|pass| pass.status == AutopilotPassStatus::Rejected)
2044 {
2045 AutopilotStatus::PartiallyApplied
2046 } else {
2047 AutopilotStatus::Applied
2048 }
2049}
2050
2051fn autopilot_note(run: &AutopilotRun) -> String {
2052 match run.status {
2053 AutopilotStatus::Reviewed => format!(
2054 "reviewed {} candidate(s) across {} pass(es); rerun with --apply to land validated transactions",
2055 run.total_executed_candidates,
2056 run.passes.len()
2057 ),
2058 AutopilotStatus::Applied => format!(
2059 "applied {} candidate(s) across {} pass(es) with fresh plans before each pass",
2060 run.total_executed_candidates,
2061 run.passes.len()
2062 ),
2063 AutopilotStatus::PartiallyApplied => format!(
2064 "applied {} candidate(s) before an execution gate stopped the run",
2065 run.total_executed_candidates
2066 ),
2067 AutopilotStatus::NoExecutableCandidates => {
2068 if run.budget_exhausted {
2069 "budget exhausted before more executable work could run".to_string()
2070 } else {
2071 "no executable low-risk candidates were available".to_string()
2072 }
2073 }
2074 AutopilotStatus::Rejected => {
2075 "autopilot stopped because a planning or execution gate rejected the run".to_string()
2076 }
2077 }
2078}
2079
2080fn autopilot_execution_summary(run: &AutopilotRun) -> AutopilotExecutionSummary {
2081 let plans_created = run.passes.len();
2082 let executable_candidates_seen = run
2083 .passes
2084 .iter()
2085 .map(|pass| pass.executable_candidates)
2086 .sum();
2087 let validated_transactions = run
2088 .passes
2089 .iter()
2090 .filter_map(|pass| pass.batch.as_ref())
2091 .flat_map(|batch| batch.steps.iter())
2092 .filter(|step| {
2093 step.hardening_run
2094 .as_ref()
2095 .is_some_and(|hardening| hardening.outcome.isolated_validation_passed)
2096 })
2097 .count();
2098 let applied_transactions = run
2099 .passes
2100 .iter()
2101 .filter_map(|pass| pass.batch.as_ref())
2102 .flat_map(|batch| batch.steps.iter())
2103 .filter(|step| {
2104 step.hardening_run
2105 .as_ref()
2106 .is_some_and(|hardening| hardening.outcome.applied)
2107 })
2108 .count();
2109 let blocked_or_plan_only_candidates = run
2110 .total_planned_candidates
2111 .saturating_sub(executable_candidates_seen);
2112
2113 AutopilotExecutionSummary {
2114 plans_created,
2115 executable_candidates_seen,
2116 validated_transactions,
2117 applied_transactions,
2118 blocked_or_plan_only_candidates,
2119 evidence_grade: run.evidence.grade,
2120 analysis_depth: run.evidence.analysis_depth.clone(),
2121 }
2122}
2123
2124fn batch_status(apply: bool, executed: usize, requested: usize) -> RefactorBatchApplyStatus {
2125 if requested == 0 {
2126 RefactorBatchApplyStatus::NoExecutableCandidates
2127 } else if executed == 0 {
2128 RefactorBatchApplyStatus::Rejected
2129 } else if !apply {
2130 RefactorBatchApplyStatus::Reviewed
2131 } else if executed == requested {
2132 RefactorBatchApplyStatus::Applied
2133 } else {
2134 RefactorBatchApplyStatus::PartiallyApplied
2135 }
2136}
2137
2138fn safe_relative_path(value: &str) -> anyhow::Result<PathBuf> {
2139 let path = PathBuf::from(value);
2140 if path.is_absolute()
2141 || path.components().any(|component| {
2142 matches!(
2143 component,
2144 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2145 )
2146 })
2147 {
2148 anyhow::bail!("refactor plan contains unscoped path: {value}");
2149 }
2150 Ok(path)
2151}
2152
2153fn sanitize_id(value: &str) -> String {
2154 value
2155 .chars()
2156 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
2157 .collect::<String>()
2158 .trim_matches('-')
2159 .to_string()
2160}
2161
2162fn persist_refactor_plan(artifact_root: &Path, plan: &RefactorPlan) -> anyhow::Result<PathBuf> {
2163 let dir = artifact_root.join("plans");
2164 std::fs::create_dir_all(&dir)?;
2165 let millis = std::time::SystemTime::now()
2166 .duration_since(std::time::UNIX_EPOCH)
2167 .map(|duration| duration.as_millis())
2168 .unwrap_or(0);
2169 Ok(dir.join(format!("refactor-plan-{millis}-{}.json", plan.plan_id)))
2170}
2171
2172fn persist_apply_run(
2173 artifact_root: Option<&Path>,
2174 mut run: RefactorApplyRun,
2175) -> anyhow::Result<RefactorApplyRun> {
2176 if let Some(artifact_root) = artifact_root {
2177 let dir = artifact_root.join("plans");
2178 std::fs::create_dir_all(&dir)?;
2179 let millis = std::time::SystemTime::now()
2180 .duration_since(std::time::UNIX_EPOCH)
2181 .map(|duration| duration.as_millis())
2182 .unwrap_or(0);
2183 let path = dir.join(format!(
2184 "apply-plan-{millis}-{}-{}.json",
2185 sanitize_id(&run.plan_id),
2186 sanitize_id(&run.candidate_id)
2187 ));
2188 run.artifact_path = Some(path.display().to_string());
2189 std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
2190 }
2191 Ok(run)
2192}
2193
2194fn persist_batch_apply_run(
2195 artifact_root: Option<&Path>,
2196 mut run: RefactorBatchApplyRun,
2197) -> anyhow::Result<RefactorBatchApplyRun> {
2198 if let Some(artifact_root) = artifact_root {
2199 let dir = artifact_root.join("plans");
2200 std::fs::create_dir_all(&dir)?;
2201 let millis = std::time::SystemTime::now()
2202 .duration_since(std::time::UNIX_EPOCH)
2203 .map(|duration| duration.as_millis())
2204 .unwrap_or(0);
2205 let path = dir.join(format!(
2206 "apply-plan-batch-{millis}-{}.json",
2207 sanitize_id(&run.plan_id)
2208 ));
2209 run.artifact_path = Some(path.display().to_string());
2210 std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
2211 }
2212 Ok(run)
2213}
2214
2215fn persist_codebase_map(artifact_root: &Path, map: &CodebaseMap) -> anyhow::Result<PathBuf> {
2216 let dir = artifact_root.join("maps");
2217 std::fs::create_dir_all(&dir)?;
2218 let millis = std::time::SystemTime::now()
2219 .duration_since(std::time::UNIX_EPOCH)
2220 .map(|duration| duration.as_millis())
2221 .unwrap_or(0);
2222 Ok(dir.join(format!(
2223 "codebase-map-{millis}-{}.json",
2224 sanitize_id(&map.map_id)
2225 )))
2226}
2227
2228fn persist_autopilot_run(
2229 artifact_root: Option<&Path>,
2230 mut run: AutopilotRun,
2231) -> anyhow::Result<AutopilotRun> {
2232 if let Some(artifact_root) = artifact_root {
2233 let dir = artifact_root.join("autopilot");
2234 std::fs::create_dir_all(&dir)?;
2235 let millis = std::time::SystemTime::now()
2236 .duration_since(std::time::UNIX_EPOCH)
2237 .map(|duration| duration.as_millis())
2238 .unwrap_or(0);
2239 let path = dir.join(format!(
2240 "autopilot-{millis}-{}.json",
2241 sanitize_id(&run.run_id)
2242 ));
2243 run.artifact_path = Some(path.display().to_string());
2244 std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
2245 }
2246 Ok(run)
2247}
2248
2249#[cfg(test)]
2250mod tests {
2251 use super::*;
2252 use tempfile::tempdir;
2253
2254 #[test]
2255 fn refactor_plan_points_patchable_changes_to_improve() {
2256 let dir = tempdir().unwrap();
2257 std::fs::write(
2258 dir.path().join("Cargo.toml"),
2259 r#"[package]
2260name = "plan-fixture"
2261version = "0.1.0"
2262edition = "2021"
2263
2264[dependencies]
2265anyhow = "1"
2266"#,
2267 )
2268 .unwrap();
2269 std::fs::create_dir_all(dir.path().join("src")).unwrap();
2270 std::fs::write(
2271 dir.path().join("src/lib.rs"),
2272 r#"pub fn load_config() -> anyhow::Result<String> {
2273 let content = std::fs::read_to_string("missing.toml").unwrap();
2274 Ok(content)
2275}
2276"#,
2277 )
2278 .unwrap();
2279
2280 let plan = build_refactor_plan(
2281 dir.path(),
2282 None,
2283 &RefactorPlanConfig {
2284 target: Some(PathBuf::from("src/lib.rs")),
2285 behavior_spec_path: Some(PathBuf::from(".mdx-rust/evals.json")),
2286 ..RefactorPlanConfig::default()
2287 },
2288 )
2289 .unwrap();
2290
2291 assert_eq!(plan.schema_version, "0.7");
2292 assert!(plan.candidates.iter().any(|candidate| candidate.status
2293 == RefactorCandidateStatus::ApplyViaImprove
2294 && candidate
2295 .apply_command
2296 .as_deref()
2297 .is_some_and(|command| command.contains("--eval-spec"))));
2298 }
2299
2300 #[test]
2301 fn tested_evidence_surfaces_boundary_review_candidates() {
2302 let dir = tempdir().unwrap();
2303 std::fs::write(
2304 dir.path().join("Cargo.toml"),
2305 r#"[package]
2306name = "tested-plan-fixture"
2307version = "0.1.0"
2308edition = "2021"
2309"#,
2310 )
2311 .unwrap();
2312 std::fs::create_dir_all(dir.path().join("src")).unwrap();
2313 std::fs::write(
2314 dir.path().join("src/lib.rs"),
2315 r#"pub fn shell(cmd: &str) {
2316 std::process::Command::new(cmd);
2317}
2318
2319#[cfg(test)]
2320mod tests {
2321 #[test]
2322 fn smoke() {
2323 assert_eq!(1, 1);
2324 }
2325}
2326"#,
2327 )
2328 .unwrap();
2329
2330 let plan = build_refactor_plan(
2331 dir.path(),
2332 None,
2333 &RefactorPlanConfig {
2334 target: Some(PathBuf::from("src/lib.rs")),
2335 ..RefactorPlanConfig::default()
2336 },
2337 )
2338 .unwrap();
2339
2340 assert_eq!(plan.evidence.grade, EvidenceGrade::Tested);
2341 assert_eq!(
2342 plan.evidence.analysis_depth,
2343 EvidenceAnalysisDepth::BoundaryAware
2344 );
2345 assert!(plan.candidates.iter().any(|candidate| candidate.status
2346 == RefactorCandidateStatus::PlanOnly
2347 && candidate.required_evidence == EvidenceGrade::Tested
2348 && candidate.tier == RecipeTier::Tier2));
2349 }
2350
2351 #[test]
2352 fn measured_covered_evidence_unlocks_tier2_executable_recipe() {
2353 let dir = tempdir().unwrap();
2354 std::fs::write(
2355 dir.path().join("Cargo.toml"),
2356 r#"[package]
2357name = "covered-plan-fixture"
2358version = "0.1.0"
2359edition = "2021"
2360"#,
2361 )
2362 .unwrap();
2363 std::fs::create_dir_all(dir.path().join("src")).unwrap();
2364 std::fs::write(
2365 dir.path().join("src/lib.rs"),
2366 r#"pub fn labels(items: &[String]) -> Vec<&'static str> {
2367 if items.len() == 0 {
2368 return vec!["shared boundary label"];
2369 }
2370 vec![
2371 "shared boundary label",
2372 "shared boundary label",
2373 "shared boundary label",
2374 ]
2375}
2376"#,
2377 )
2378 .unwrap();
2379 let artifact_root = dir.path().join(".mdx-rust");
2380 std::fs::create_dir_all(artifact_root.join("evidence")).unwrap();
2381 let evidence = crate::evidence::EvidenceRun {
2382 schema_version: "0.7".to_string(),
2383 run_id: "covered-fixture".to_string(),
2384 root: dir.path().canonicalize().unwrap().display().to_string(),
2385 target: Some("src/lib.rs".to_string()),
2386 grade: EvidenceGrade::Covered,
2387 analysis_depth: EvidenceAnalysisDepth::Structural,
2388 metrics: Vec::new(),
2389 commands: Vec::new(),
2390 unlocked_recipe_tiers: vec!["Tier 2 structural mechanical recipes".to_string()],
2391 unlock_suggestions: Vec::new(),
2392 note: "fixture evidence".to_string(),
2393 artifact_path: Some(
2394 artifact_root
2395 .join("evidence/evidence-fixture.json")
2396 .display()
2397 .to_string(),
2398 ),
2399 };
2400 std::fs::write(
2401 artifact_root.join("evidence/evidence-fixture.json"),
2402 serde_json::to_string_pretty(&evidence).unwrap(),
2403 )
2404 .unwrap();
2405
2406 let plan = build_refactor_plan(
2407 dir.path(),
2408 Some(&artifact_root),
2409 &RefactorPlanConfig {
2410 target: Some(PathBuf::from("src/lib.rs")),
2411 ..RefactorPlanConfig::default()
2412 },
2413 )
2414 .unwrap();
2415
2416 assert_eq!(plan.evidence.grade, EvidenceGrade::Covered);
2417 assert!(plan.measured_evidence.is_some());
2418 assert!(plan.candidates.iter().any(|candidate| candidate.recipe
2419 == RefactorRecipe::RepeatedStringLiteralConst
2420 && candidate.status == RefactorCandidateStatus::ApplyViaImprove
2421 && candidate.required_evidence == EvidenceGrade::Covered
2422 && candidate.tier == RecipeTier::Tier2
2423 && candidate
2424 .apply_command
2425 .as_deref()
2426 .is_some_and(|command| command.contains("--tier 2"))));
2427 assert!(plan.candidates.iter().any(|candidate| candidate.recipe
2428 == RefactorRecipe::LenCheckIsEmpty
2429 && candidate.status == RefactorCandidateStatus::ApplyViaImprove
2430 && candidate.required_evidence == EvidenceGrade::Covered
2431 && candidate.tier == RecipeTier::Tier2));
2432 }
2433}