1use crate::eval::stable_hash_hex;
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, HardeningFinding, ModuleEdge,
14 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}
57
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct RefactorPlan {
60 pub schema_version: String,
61 pub plan_id: String,
62 pub plan_hash: String,
63 pub root: String,
64 pub target: Option<String>,
65 pub workspace: WorkspaceSummary,
66 pub policy: Option<ProjectPolicy>,
67 pub behavior_spec: Option<String>,
68 pub impact: RefactorImpactSummary,
69 pub source_snapshots: Vec<SourceSnapshot>,
70 pub files: Vec<RefactorFileSummary>,
71 pub module_edges: Vec<ModuleEdge>,
72 pub candidates: Vec<RefactorCandidate>,
73 pub required_gates: Vec<String>,
74 pub non_goals: Vec<String>,
75 pub artifact_path: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79pub struct SourceSnapshot {
80 pub file: String,
81 pub hash: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct RefactorImpactSummary {
86 pub files_scanned: usize,
87 pub public_item_count: usize,
88 pub public_files: usize,
89 pub module_edge_count: usize,
90 pub patchable_hardening_changes: usize,
91 pub review_only_findings: usize,
92 pub oversized_files: usize,
93 pub oversized_functions: usize,
94 pub risk_level: RefactorRiskLevel,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
98pub enum RefactorRiskLevel {
99 Low,
100 Medium,
101 High,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
105pub struct RefactorCandidate {
106 pub id: String,
107 pub candidate_hash: String,
108 pub recipe: RefactorRecipe,
109 pub title: String,
110 pub rationale: String,
111 pub file: String,
112 pub line: usize,
113 pub risk: RefactorRiskLevel,
114 pub status: RefactorCandidateStatus,
115 pub public_api_impact: bool,
116 pub apply_command: Option<String>,
117 pub required_gates: Vec<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
121pub enum RefactorCandidateStatus {
122 ApplyViaImprove,
123 PlanOnly,
124 NeedsHumanDesign,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
128pub enum RefactorRecipe {
129 ContextualErrorHardening,
130 ExtractFunctionCandidate,
131 SplitModuleCandidate,
132 BoundaryValidationReview,
133 PublicApiReview,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
137pub struct RefactorApplyRun {
138 pub schema_version: String,
139 pub root: String,
140 pub plan_path: String,
141 pub plan_id: String,
142 pub plan_hash: String,
143 pub candidate_id: String,
144 pub candidate_hash: Option<String>,
145 pub mode: RefactorApplyMode,
146 pub status: RefactorApplyStatus,
147 pub public_api_impact_allowed: bool,
148 pub stale_files: Vec<StaleSourceFile>,
149 pub hardening_run: Option<HardeningRun>,
150 pub note: String,
151 pub artifact_path: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155pub struct RefactorBatchApplyRun {
156 pub schema_version: String,
157 pub root: String,
158 pub plan_path: String,
159 pub plan_id: String,
160 pub plan_hash: String,
161 pub mode: RefactorApplyMode,
162 pub status: RefactorBatchApplyStatus,
163 pub public_api_impact_allowed: bool,
164 pub max_candidates: usize,
165 pub requested_candidates: usize,
166 pub executed_candidates: usize,
167 pub skipped_candidates: usize,
168 pub steps: Vec<RefactorBatchCandidateRun>,
169 pub note: String,
170 pub artifact_path: Option<String>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
174pub struct RefactorBatchCandidateRun {
175 pub candidate_id: String,
176 pub candidate_hash: Option<String>,
177 pub file: String,
178 pub status: RefactorApplyStatus,
179 pub stale_file: Option<StaleSourceFile>,
180 pub hardening_run: Option<HardeningRun>,
181 pub note: String,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
185pub enum RefactorApplyMode {
186 Review,
187 Apply,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
191pub enum RefactorBatchApplyStatus {
192 Reviewed,
193 Applied,
194 PartiallyApplied,
195 Rejected,
196 StalePlan,
197 NoExecutableCandidates,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
201pub enum RefactorApplyStatus {
202 Reviewed,
203 Applied,
204 Rejected,
205 StalePlan,
206 Unsupported,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
210pub struct StaleSourceFile {
211 pub file: String,
212 pub expected_hash: String,
213 pub actual_hash: String,
214}
215
216pub fn build_refactor_plan(
217 root: &Path,
218 artifact_root: Option<&Path>,
219 config: &RefactorPlanConfig,
220) -> anyhow::Result<RefactorPlan> {
221 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
222 let refactor = analyze_refactor(
223 &root,
224 RefactorAnalyzeConfig {
225 target: config.target.as_deref(),
226 max_files: config.max_files,
227 },
228 )?;
229 let hardening = analyze_hardening(
230 &root,
231 HardeningAnalyzeConfig {
232 target: config.target.as_deref(),
233 max_files: config.max_files,
234 },
235 )?;
236 let policy = load_project_policy(&root, config.policy_path.as_deref())?;
237 let workspace = workspace_summary(&root);
238 let behavior_spec = config
239 .behavior_spec_path
240 .as_ref()
241 .map(|path| path.display().to_string());
242 let impact = summarize_impact(
243 &refactor.files,
244 refactor.module_edges.len(),
245 &hardening.findings,
246 hardening.changes.len(),
247 );
248 let mut candidates = Vec::new();
249 candidates.extend(hardening_candidates(&hardening.findings, config));
250 candidates.extend(structural_candidates(&refactor.files));
251 for candidate in &mut candidates {
252 candidate.candidate_hash = candidate_hash(candidate);
253 }
254 candidates.sort_by(|left, right| left.id.cmp(&right.id));
255 let source_snapshots = source_snapshots(&root, &refactor.files)?;
256
257 let required_gates = required_gates(config.behavior_spec_path.is_some());
258 let non_goals = vec![
259 "No autonomous broad multi-file refactors in v0.5.".to_string(),
260 "No public API changes without explicit human review.".to_string(),
261 "No plan candidate may bypass improve/apply validation gates.".to_string(),
262 ];
263
264 let plan_id = plan_id(&root, config, &impact, &candidates);
265 let mut plan = RefactorPlan {
266 schema_version: "0.5".to_string(),
267 plan_id,
268 plan_hash: String::new(),
269 root: root.display().to_string(),
270 target: config
271 .target
272 .as_ref()
273 .map(|path| path.display().to_string()),
274 workspace,
275 policy,
276 behavior_spec,
277 impact,
278 source_snapshots,
279 files: refactor.files,
280 module_edges: refactor.module_edges,
281 candidates,
282 required_gates,
283 non_goals,
284 artifact_path: None,
285 };
286 plan.plan_hash = refactor_plan_hash(&plan);
287
288 if let Some(artifact_root) = artifact_root {
289 let path = persist_refactor_plan(artifact_root, &plan)?;
290 plan.artifact_path = Some(path.display().to_string());
291 std::fs::write(&path, serde_json::to_string_pretty(&plan)?)?;
292 }
293
294 Ok(plan)
295}
296
297pub fn apply_refactor_plan_candidate(
298 root: &Path,
299 artifact_root: Option<&Path>,
300 config: &RefactorApplyConfig,
301) -> anyhow::Result<RefactorApplyRun> {
302 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
303 let plan_content = std::fs::read_to_string(&config.plan_path)?;
304 let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
305 let mode = if config.apply {
306 RefactorApplyMode::Apply
307 } else {
308 RefactorApplyMode::Review
309 };
310 let mut run = RefactorApplyRun {
311 schema_version: "0.5".to_string(),
312 root: root.display().to_string(),
313 plan_path: config.plan_path.display().to_string(),
314 plan_id: plan.plan_id.clone(),
315 plan_hash: plan.plan_hash.clone(),
316 candidate_id: config.candidate_id.clone(),
317 candidate_hash: None,
318 mode,
319 status: RefactorApplyStatus::Rejected,
320 public_api_impact_allowed: config.allow_public_api_impact,
321 stale_files: Vec::new(),
322 hardening_run: None,
323 note: String::new(),
324 artifact_path: None,
325 };
326
327 let actual_plan_hash = refactor_plan_hash(&plan);
328 if actual_plan_hash != plan.plan_hash {
329 run.status = RefactorApplyStatus::Rejected;
330 run.note = format!(
331 "plan hash mismatch: expected {} but recomputed {}",
332 plan.plan_hash, actual_plan_hash
333 );
334 return persist_apply_run(artifact_root, run);
335 }
336
337 let stale_files = stale_source_files(&root, &plan.source_snapshots)?;
338 if !stale_files.is_empty() {
339 run.status = RefactorApplyStatus::StalePlan;
340 run.stale_files = stale_files;
341 run.note =
342 "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
343 return persist_apply_run(artifact_root, run);
344 }
345
346 let Some(candidate) = plan
347 .candidates
348 .iter()
349 .find(|candidate| candidate.id == config.candidate_id)
350 else {
351 run.status = RefactorApplyStatus::Rejected;
352 run.note = "candidate id was not found in the refactor plan".to_string();
353 return persist_apply_run(artifact_root, run);
354 };
355 run.candidate_hash = Some(candidate.candidate_hash.clone());
356
357 let actual_candidate_hash = candidate_hash(candidate);
358 if actual_candidate_hash != candidate.candidate_hash {
359 run.status = RefactorApplyStatus::Rejected;
360 run.note = format!(
361 "candidate hash mismatch: expected {} but recomputed {}",
362 candidate.candidate_hash, actual_candidate_hash
363 );
364 return persist_apply_run(artifact_root, run);
365 }
366
367 if candidate.public_api_impact && !config.allow_public_api_impact {
368 run.status = RefactorApplyStatus::Rejected;
369 run.note = "candidate touches public API impact area; pass --allow-public-api-impact after human review".to_string();
370 return persist_apply_run(artifact_root, run);
371 }
372
373 if candidate.status != RefactorCandidateStatus::ApplyViaImprove
374 || candidate.recipe != RefactorRecipe::ContextualErrorHardening
375 {
376 run.status = RefactorApplyStatus::Unsupported;
377 run.note =
378 "candidate is plan-only in v0.5; no executable recipe is available yet".to_string();
379 return persist_apply_run(artifact_root, run);
380 }
381
382 let hardening = run_hardening(
383 &root,
384 artifact_root,
385 &HardeningConfig {
386 target: Some(PathBuf::from(&candidate.file)),
387 policy_path: plan
388 .policy
389 .as_ref()
390 .map(|policy| PathBuf::from(policy.path.clone())),
391 behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
392 apply: config.apply,
393 max_files: 1,
394 validation_timeout: config.validation_timeout,
395 },
396 )?;
397
398 run.status = if config.apply {
399 if hardening.outcome.applied {
400 RefactorApplyStatus::Applied
401 } else {
402 RefactorApplyStatus::Rejected
403 }
404 } else if hardening.changes.is_empty() {
405 RefactorApplyStatus::Rejected
406 } else {
407 RefactorApplyStatus::Reviewed
408 };
409 run.note = format!(
410 "executed candidate through hardening transaction; hardening status: {:?}",
411 hardening.outcome.status
412 );
413 run.hardening_run = Some(hardening);
414 persist_apply_run(artifact_root, run)
415}
416
417pub fn apply_refactor_plan_batch(
418 root: &Path,
419 artifact_root: Option<&Path>,
420 config: &RefactorBatchApplyConfig,
421) -> anyhow::Result<RefactorBatchApplyRun> {
422 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
423 let plan_content = std::fs::read_to_string(&config.plan_path)?;
424 let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
425 let mode = if config.apply {
426 RefactorApplyMode::Apply
427 } else {
428 RefactorApplyMode::Review
429 };
430 let mut run = RefactorBatchApplyRun {
431 schema_version: "0.5".to_string(),
432 root: root.display().to_string(),
433 plan_path: config.plan_path.display().to_string(),
434 plan_id: plan.plan_id.clone(),
435 plan_hash: plan.plan_hash.clone(),
436 mode,
437 status: RefactorBatchApplyStatus::Rejected,
438 public_api_impact_allowed: config.allow_public_api_impact,
439 max_candidates: config.max_candidates,
440 requested_candidates: 0,
441 executed_candidates: 0,
442 skipped_candidates: 0,
443 steps: Vec::new(),
444 note: String::new(),
445 artifact_path: None,
446 };
447
448 let actual_plan_hash = refactor_plan_hash(&plan);
449 if actual_plan_hash != plan.plan_hash {
450 run.status = RefactorBatchApplyStatus::Rejected;
451 run.note = format!(
452 "plan hash mismatch: expected {} but recomputed {}",
453 plan.plan_hash, actual_plan_hash
454 );
455 return persist_batch_apply_run(artifact_root, run);
456 }
457
458 let initial_stale_files = stale_source_files(&root, &plan.source_snapshots)?;
459 if !initial_stale_files.is_empty() {
460 run.status = RefactorBatchApplyStatus::StalePlan;
461 run.steps = initial_stale_files
462 .into_iter()
463 .map(|stale| RefactorBatchCandidateRun {
464 candidate_id: String::new(),
465 candidate_hash: None,
466 file: stale.file.clone(),
467 status: RefactorApplyStatus::StalePlan,
468 stale_file: Some(stale),
469 hardening_run: None,
470 note: "source snapshot no longer matches the workspace".to_string(),
471 })
472 .collect();
473 run.note =
474 "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
475 return persist_batch_apply_run(artifact_root, run);
476 }
477
478 let queue = executable_candidate_queue(&plan, config);
479 run.requested_candidates = queue.len();
480 if queue.is_empty() {
481 run.status = RefactorBatchApplyStatus::NoExecutableCandidates;
482 run.note = "no executable low-risk candidates were available in the plan".to_string();
483 return persist_batch_apply_run(artifact_root, run);
484 }
485
486 for candidate in queue {
487 let mut step = RefactorBatchCandidateRun {
488 candidate_id: candidate.id.clone(),
489 candidate_hash: Some(candidate.candidate_hash.clone()),
490 file: candidate.file.clone(),
491 status: RefactorApplyStatus::Rejected,
492 stale_file: None,
493 hardening_run: None,
494 note: String::new(),
495 };
496
497 let actual_candidate_hash = candidate_hash(candidate);
498 if actual_candidate_hash != candidate.candidate_hash {
499 step.note = format!(
500 "candidate hash mismatch: expected {} but recomputed {}",
501 candidate.candidate_hash, actual_candidate_hash
502 );
503 run.skipped_candidates += 1;
504 run.steps.push(step);
505 if config.apply {
506 break;
507 }
508 continue;
509 }
510
511 if let Some(stale) = stale_file_for_candidate(&root, &plan, &candidate.file)? {
512 step.status = RefactorApplyStatus::StalePlan;
513 step.stale_file = Some(stale);
514 step.note =
515 "candidate source file changed after planning; re-run mdx-rust plan".to_string();
516 run.skipped_candidates += 1;
517 run.steps.push(step);
518 if config.apply {
519 break;
520 }
521 continue;
522 }
523
524 let hardening = run_hardening(
525 &root,
526 artifact_root,
527 &HardeningConfig {
528 target: Some(PathBuf::from(&candidate.file)),
529 policy_path: plan
530 .policy
531 .as_ref()
532 .map(|policy| PathBuf::from(policy.path.clone())),
533 behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
534 apply: config.apply,
535 max_files: 1,
536 validation_timeout: config.validation_timeout,
537 },
538 )?;
539
540 step.status = if config.apply {
541 if hardening.outcome.applied {
542 RefactorApplyStatus::Applied
543 } else {
544 RefactorApplyStatus::Rejected
545 }
546 } else if hardening.changes.is_empty() {
547 RefactorApplyStatus::Rejected
548 } else {
549 RefactorApplyStatus::Reviewed
550 };
551 step.note = format!(
552 "executed candidate through hardening transaction; hardening status: {:?}",
553 hardening.outcome.status
554 );
555 step.hardening_run = Some(hardening);
556
557 if matches!(
558 step.status,
559 RefactorApplyStatus::Reviewed | RefactorApplyStatus::Applied
560 ) {
561 run.executed_candidates += 1;
562 } else {
563 run.skipped_candidates += 1;
564 }
565
566 let failed_apply_step = config.apply && step.status != RefactorApplyStatus::Applied;
567 run.steps.push(step);
568 if failed_apply_step {
569 break;
570 }
571 }
572
573 run.status = batch_status(
574 config.apply,
575 run.executed_candidates,
576 run.requested_candidates,
577 );
578 run.note = format!(
579 "processed {} executable candidate(s); executed {}, skipped {}",
580 run.requested_candidates, run.executed_candidates, run.skipped_candidates
581 );
582 persist_batch_apply_run(artifact_root, run)
583}
584
585fn summarize_impact(
586 files: &[RefactorFileSummary],
587 module_edge_count: usize,
588 findings: &[HardeningFinding],
589 patchable_hardening_changes: usize,
590) -> RefactorImpactSummary {
591 let public_item_count = files.iter().map(|file| file.public_item_count).sum();
592 let public_files = files
593 .iter()
594 .filter(|file| file.public_item_count > 0)
595 .count();
596 let oversized_files = files.iter().filter(|file| file.line_count >= 300).count();
597 let oversized_functions = files
598 .iter()
599 .filter(|file| file.largest_function_lines >= 80)
600 .count();
601 let review_only_findings = findings.iter().filter(|finding| !finding.patchable).count();
602 let risk_level = if public_item_count > 10 || oversized_files > 2 {
603 RefactorRiskLevel::High
604 } else if public_item_count > 0 || oversized_files > 0 || oversized_functions > 0 {
605 RefactorRiskLevel::Medium
606 } else {
607 RefactorRiskLevel::Low
608 };
609
610 RefactorImpactSummary {
611 files_scanned: files.len(),
612 public_item_count,
613 public_files,
614 module_edge_count,
615 patchable_hardening_changes,
616 review_only_findings,
617 oversized_files,
618 oversized_functions,
619 risk_level,
620 }
621}
622
623fn hardening_candidates(
624 findings: &[HardeningFinding],
625 config: &RefactorPlanConfig,
626) -> Vec<RefactorCandidate> {
627 findings
628 .iter()
629 .filter(|finding| finding.patchable)
630 .map(|finding| {
631 let file = finding.file.display().to_string();
632 RefactorCandidate {
633 id: format!("plan-hardening-{}-{}", sanitize_id(&file), finding.line),
634 candidate_hash: String::new(),
635 recipe: RefactorRecipe::ContextualErrorHardening,
636 title: finding.title.clone(),
637 rationale: "Patchable contextual error hardening can be applied through the existing isolated validation transaction.".to_string(),
638 file: file.clone(),
639 line: finding.line,
640 risk: RefactorRiskLevel::Low,
641 status: RefactorCandidateStatus::ApplyViaImprove,
642 public_api_impact: false,
643 apply_command: Some(apply_command(&file, config)),
644 required_gates: required_gates(config.behavior_spec_path.is_some()),
645 }
646 })
647 .collect()
648}
649
650fn structural_candidates(files: &[RefactorFileSummary]) -> Vec<RefactorCandidate> {
651 let mut candidates = Vec::new();
652 for file in files {
653 let file_path = file.file.display().to_string();
654 if file.line_count >= 300 {
655 candidates.push(RefactorCandidate {
656 id: format!("plan-split-module-{}", sanitize_id(&file_path)),
657 candidate_hash: String::new(),
658 recipe: RefactorRecipe::SplitModuleCandidate,
659 title: "Split oversized module".to_string(),
660 rationale: format!(
661 "{} has {} lines. Split only after reviewing public API and module edges.",
662 file_path, file.line_count
663 ),
664 file: file_path.clone(),
665 line: 1,
666 risk: if file.public_item_count > 0 {
667 RefactorRiskLevel::High
668 } else {
669 RefactorRiskLevel::Medium
670 },
671 status: RefactorCandidateStatus::NeedsHumanDesign,
672 public_api_impact: file.public_item_count > 0,
673 apply_command: None,
674 required_gates: vec![
675 "human design review".to_string(),
676 "cargo check".to_string(),
677 "cargo clippy -- -D warnings".to_string(),
678 "behavior evals when configured".to_string(),
679 ],
680 });
681 }
682
683 if file.largest_function_lines >= 80 {
684 candidates.push(RefactorCandidate {
685 id: format!("plan-extract-function-{}", sanitize_id(&file_path)),
686 candidate_hash: String::new(),
687 recipe: RefactorRecipe::ExtractFunctionCandidate,
688 title: "Extract long function".to_string(),
689 rationale: format!(
690 "Largest function in {} is {} lines. Extract only with behavior coverage in place.",
691 file_path, file.largest_function_lines
692 ),
693 file: file_path.clone(),
694 line: 1,
695 risk: RefactorRiskLevel::Medium,
696 status: RefactorCandidateStatus::PlanOnly,
697 public_api_impact: file.public_item_count > 0,
698 apply_command: None,
699 required_gates: vec![
700 "targeted tests or behavior evals".to_string(),
701 "cargo check".to_string(),
702 "cargo clippy -- -D warnings".to_string(),
703 ],
704 });
705 }
706
707 if file.public_item_count > 0 {
708 candidates.push(RefactorCandidate {
709 id: format!("plan-public-api-{}", sanitize_id(&file_path)),
710 candidate_hash: String::new(),
711 recipe: RefactorRecipe::PublicApiReview,
712 title: "Protect public API before refactoring".to_string(),
713 rationale: format!(
714 "{} exposes {} public item(s). Treat signature changes as semver-impacting.",
715 file_path, file.public_item_count
716 ),
717 file: file_path,
718 line: 1,
719 risk: RefactorRiskLevel::Medium,
720 status: RefactorCandidateStatus::PlanOnly,
721 public_api_impact: true,
722 apply_command: None,
723 required_gates: vec![
724 "public API review".to_string(),
725 "docs and changelog review for exported changes".to_string(),
726 ],
727 });
728 }
729 }
730
731 candidates
732}
733
734fn required_gates(has_behavior_spec: bool) -> Vec<String> {
735 let mut gates = vec![
736 "cargo check".to_string(),
737 "cargo clippy -- -D warnings".to_string(),
738 "review plan artifact before applying".to_string(),
739 ];
740 if has_behavior_spec {
741 gates.push("behavior eval spec must pass in isolation and after apply".to_string());
742 }
743 gates
744}
745
746fn apply_command(file: &str, config: &RefactorPlanConfig) -> String {
747 let mut command = format!("mdx-rust improve {} --apply", shell_word_str(file));
748 if let Some(policy) = &config.policy_path {
749 command.push_str(&format!(" --policy {}", shell_word_path(policy)));
750 }
751 if let Some(eval_spec) = &config.behavior_spec_path {
752 command.push_str(&format!(" --eval-spec {}", shell_word_path(eval_spec)));
753 }
754 command
755}
756
757fn shell_word_path(path: &Path) -> String {
758 shell_word_str(&path.display().to_string())
759}
760
761fn shell_word_str(value: &str) -> String {
762 if value
763 .chars()
764 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
765 {
766 value.to_string()
767 } else {
768 format!("'{}'", value.replace('\'', "'\\''"))
769 }
770}
771
772fn plan_id(
773 root: &Path,
774 config: &RefactorPlanConfig,
775 impact: &RefactorImpactSummary,
776 candidates: &[RefactorCandidate],
777) -> String {
778 let mut bytes = Vec::new();
779 bytes.extend_from_slice(root.display().to_string().as_bytes());
780 bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
781 bytes.extend_from_slice(format!("{:?}", config.policy_path).as_bytes());
782 bytes.extend_from_slice(format!("{:?}", config.behavior_spec_path).as_bytes());
783 bytes.extend_from_slice(format!("{impact:?}").as_bytes());
784 bytes.extend_from_slice(format!("{candidates:?}").as_bytes());
785 stable_hash_hex(&bytes)
786}
787
788fn refactor_plan_hash(plan: &RefactorPlan) -> String {
789 let mut bytes = Vec::new();
790 bytes.extend_from_slice(plan.schema_version.as_bytes());
791 bytes.extend_from_slice(plan.plan_id.as_bytes());
792 bytes.extend_from_slice(plan.root.as_bytes());
793 bytes.extend_from_slice(format!("{:?}", plan.target).as_bytes());
794 bytes.extend_from_slice(format!("{:?}", plan.impact).as_bytes());
795 bytes.extend_from_slice(format!("{:?}", plan.source_snapshots).as_bytes());
796 bytes.extend_from_slice(format!("{:?}", plan.module_edges).as_bytes());
797 bytes.extend_from_slice(format!("{:?}", plan.candidates).as_bytes());
798 stable_hash_hex(&bytes)
799}
800
801fn candidate_hash(candidate: &RefactorCandidate) -> String {
802 let mut bytes = Vec::new();
803 bytes.extend_from_slice(candidate.id.as_bytes());
804 bytes.extend_from_slice(format!("{:?}", candidate.recipe).as_bytes());
805 bytes.extend_from_slice(candidate.title.as_bytes());
806 bytes.extend_from_slice(candidate.rationale.as_bytes());
807 bytes.extend_from_slice(candidate.file.as_bytes());
808 bytes.extend_from_slice(candidate.line.to_string().as_bytes());
809 bytes.extend_from_slice(format!("{:?}", candidate.risk).as_bytes());
810 bytes.extend_from_slice(format!("{:?}", candidate.status).as_bytes());
811 bytes.extend_from_slice(candidate.public_api_impact.to_string().as_bytes());
812 bytes.extend_from_slice(format!("{:?}", candidate.apply_command).as_bytes());
813 stable_hash_hex(&bytes)
814}
815
816fn source_snapshots(
817 root: &Path,
818 files: &[RefactorFileSummary],
819) -> anyhow::Result<Vec<SourceSnapshot>> {
820 let mut snapshots = Vec::new();
821 for file in files {
822 let content = std::fs::read(root.join(&file.file))?;
823 snapshots.push(SourceSnapshot {
824 file: file.file.display().to_string(),
825 hash: stable_hash_hex(&content),
826 });
827 }
828 Ok(snapshots)
829}
830
831fn stale_source_files(
832 root: &Path,
833 snapshots: &[SourceSnapshot],
834) -> anyhow::Result<Vec<StaleSourceFile>> {
835 let mut stale = Vec::new();
836 for snapshot in snapshots {
837 let rel = safe_relative_path(&snapshot.file)?;
838 let actual_hash = std::fs::read(root.join(&rel))
839 .map(|content| stable_hash_hex(&content))
840 .unwrap_or_else(|_| "<missing>".to_string());
841 if actual_hash != snapshot.hash {
842 stale.push(StaleSourceFile {
843 file: snapshot.file.clone(),
844 expected_hash: snapshot.hash.clone(),
845 actual_hash,
846 });
847 }
848 }
849 Ok(stale)
850}
851
852fn stale_file_for_candidate(
853 root: &Path,
854 plan: &RefactorPlan,
855 file: &str,
856) -> anyhow::Result<Option<StaleSourceFile>> {
857 let Some(snapshot) = plan
858 .source_snapshots
859 .iter()
860 .find(|snapshot| snapshot.file == file)
861 else {
862 return Ok(Some(StaleSourceFile {
863 file: file.to_string(),
864 expected_hash: "<missing-snapshot>".to_string(),
865 actual_hash: "<unknown>".to_string(),
866 }));
867 };
868 let rel = safe_relative_path(&snapshot.file)?;
869 let actual_hash = std::fs::read(root.join(&rel))
870 .map(|content| stable_hash_hex(&content))
871 .unwrap_or_else(|_| "<missing>".to_string());
872 if actual_hash == snapshot.hash {
873 Ok(None)
874 } else {
875 Ok(Some(StaleSourceFile {
876 file: snapshot.file.clone(),
877 expected_hash: snapshot.hash.clone(),
878 actual_hash,
879 }))
880 }
881}
882
883fn executable_candidate_queue<'a>(
884 plan: &'a RefactorPlan,
885 config: &RefactorBatchApplyConfig,
886) -> Vec<&'a RefactorCandidate> {
887 let mut queue = Vec::new();
888 let mut seen_files = std::collections::BTreeSet::new();
889 for candidate in &plan.candidates {
890 if queue.len() >= config.max_candidates {
891 break;
892 }
893 if candidate.status != RefactorCandidateStatus::ApplyViaImprove
894 || candidate.recipe != RefactorRecipe::ContextualErrorHardening
895 {
896 continue;
897 }
898 if candidate.public_api_impact && !config.allow_public_api_impact {
899 continue;
900 }
901 if seen_files.insert(candidate.file.clone()) {
902 queue.push(candidate);
903 }
904 }
905 queue
906}
907
908fn batch_status(apply: bool, executed: usize, requested: usize) -> RefactorBatchApplyStatus {
909 if requested == 0 {
910 RefactorBatchApplyStatus::NoExecutableCandidates
911 } else if executed == 0 {
912 RefactorBatchApplyStatus::Rejected
913 } else if !apply {
914 RefactorBatchApplyStatus::Reviewed
915 } else if executed == requested {
916 RefactorBatchApplyStatus::Applied
917 } else {
918 RefactorBatchApplyStatus::PartiallyApplied
919 }
920}
921
922fn safe_relative_path(value: &str) -> anyhow::Result<PathBuf> {
923 let path = PathBuf::from(value);
924 if path.is_absolute()
925 || path.components().any(|component| {
926 matches!(
927 component,
928 Component::ParentDir | Component::RootDir | Component::Prefix(_)
929 )
930 })
931 {
932 anyhow::bail!("refactor plan contains unscoped path: {value}");
933 }
934 Ok(path)
935}
936
937fn sanitize_id(value: &str) -> String {
938 value
939 .chars()
940 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
941 .collect::<String>()
942 .trim_matches('-')
943 .to_string()
944}
945
946fn persist_refactor_plan(artifact_root: &Path, plan: &RefactorPlan) -> anyhow::Result<PathBuf> {
947 let dir = artifact_root.join("plans");
948 std::fs::create_dir_all(&dir)?;
949 let millis = std::time::SystemTime::now()
950 .duration_since(std::time::UNIX_EPOCH)
951 .map(|duration| duration.as_millis())
952 .unwrap_or(0);
953 Ok(dir.join(format!("refactor-plan-{millis}-{}.json", plan.plan_id)))
954}
955
956fn persist_apply_run(
957 artifact_root: Option<&Path>,
958 mut run: RefactorApplyRun,
959) -> anyhow::Result<RefactorApplyRun> {
960 if let Some(artifact_root) = artifact_root {
961 let dir = artifact_root.join("plans");
962 std::fs::create_dir_all(&dir)?;
963 let millis = std::time::SystemTime::now()
964 .duration_since(std::time::UNIX_EPOCH)
965 .map(|duration| duration.as_millis())
966 .unwrap_or(0);
967 let path = dir.join(format!(
968 "apply-plan-{millis}-{}-{}.json",
969 sanitize_id(&run.plan_id),
970 sanitize_id(&run.candidate_id)
971 ));
972 run.artifact_path = Some(path.display().to_string());
973 std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
974 }
975 Ok(run)
976}
977
978fn persist_batch_apply_run(
979 artifact_root: Option<&Path>,
980 mut run: RefactorBatchApplyRun,
981) -> anyhow::Result<RefactorBatchApplyRun> {
982 if let Some(artifact_root) = artifact_root {
983 let dir = artifact_root.join("plans");
984 std::fs::create_dir_all(&dir)?;
985 let millis = std::time::SystemTime::now()
986 .duration_since(std::time::UNIX_EPOCH)
987 .map(|duration| duration.as_millis())
988 .unwrap_or(0);
989 let path = dir.join(format!(
990 "apply-plan-batch-{millis}-{}.json",
991 sanitize_id(&run.plan_id)
992 ));
993 run.artifact_path = Some(path.display().to_string());
994 std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
995 }
996 Ok(run)
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use tempfile::tempdir;
1003
1004 #[test]
1005 fn refactor_plan_points_patchable_changes_to_improve() {
1006 let dir = tempdir().unwrap();
1007 std::fs::write(
1008 dir.path().join("Cargo.toml"),
1009 r#"[package]
1010name = "plan-fixture"
1011version = "0.1.0"
1012edition = "2021"
1013
1014[dependencies]
1015anyhow = "1"
1016"#,
1017 )
1018 .unwrap();
1019 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1020 std::fs::write(
1021 dir.path().join("src/lib.rs"),
1022 r#"pub fn load_config() -> anyhow::Result<String> {
1023 let content = std::fs::read_to_string("missing.toml").unwrap();
1024 Ok(content)
1025}
1026"#,
1027 )
1028 .unwrap();
1029
1030 let plan = build_refactor_plan(
1031 dir.path(),
1032 None,
1033 &RefactorPlanConfig {
1034 target: Some(PathBuf::from("src/lib.rs")),
1035 behavior_spec_path: Some(PathBuf::from(".mdx-rust/evals.json")),
1036 ..RefactorPlanConfig::default()
1037 },
1038 )
1039 .unwrap();
1040
1041 assert_eq!(plan.schema_version, "0.5");
1042 assert!(plan.candidates.iter().any(|candidate| candidate.status
1043 == RefactorCandidateStatus::ApplyViaImprove
1044 && candidate
1045 .apply_command
1046 .as_deref()
1047 .is_some_and(|command| command.contains("--eval-spec"))));
1048 }
1049}