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