1pub mod analyzers;
9pub mod auto_fix;
10pub mod baseline;
11pub mod blame;
12pub mod config;
13pub mod context;
14pub mod domain;
15pub mod error;
16pub mod llm;
17pub mod parsers;
18pub mod reporters;
19pub mod sources;
20pub mod suppress;
21pub mod workspace;
22
23pub use config::{Config, ConfigSource};
24pub use context::ProjectContext;
25pub use domain::{
26 Attribution, ClaimKind, CodeFact, Confidence, Divergence, FactKind, Location, RuleId, Severity,
27 SpecClaim,
28};
29pub use error::SpecDriftError;
30
31use analyzers::DriftAnalyzer;
32use rayon::prelude::*;
33use reporters::Reporter;
34use std::collections::HashSet;
35use std::path::Path;
36use std::path::PathBuf;
37use std::process::ExitCode;
38
39pub fn run(ctx: &ProjectContext, analyzers: &[Box<dyn DriftAnalyzer>]) -> Vec<Divergence> {
52 let mut all: Vec<Divergence> = analyzers
53 .par_iter()
54 .flat_map_iter(|a| a.analyze(ctx))
55 .collect();
56
57 all.sort_by(|a, b| {
58 a.location
59 .file
60 .cmp(&b.location.file)
61 .then_with(|| a.location.line.cmp(&b.location.line))
62 .then_with(|| a.rule.as_str().cmp(b.rule.as_str()))
63 });
64 all
65}
66
67pub fn apply_config(
70 mut divs: Vec<Divergence>,
71 cfg: &Config,
72 root: &std::path::Path,
73) -> Vec<Divergence> {
74 divs.retain(|d| !cfg.is_suppressed(d, root));
75 cfg.apply_severity_overrides(&mut divs);
76 divs
77}
78
79pub fn apply_strict(mut divs: Vec<Divergence>) -> Vec<Divergence> {
83 for d in &mut divs {
84 if d.rule.confidence() != Confidence::Deterministic {
85 d.severity = d.severity.promoted();
86 }
87 }
88 divs
89}
90
91pub fn normalize_locations(mut divs: Vec<Divergence>, root: &Path) -> Vec<Divergence> {
95 for d in &mut divs {
96 if let Ok(rel) = d.location.file.strip_prefix(root) {
97 d.location.file = rel.to_path_buf();
98 }
99 }
100 divs
101}
102
103#[derive(Debug, Clone, Copy)]
109pub enum Pillar {
110 All,
111 Docs,
112 Examples,
113 Tests,
114 Ci,
115}
116
117#[derive(Debug, Clone)]
119pub struct RunConfig {
120 pub root: PathBuf,
121 pub pillar: Pillar,
122 pub format: String,
123 pub fix_prompt: bool,
124 pub config: Option<PathBuf>,
125 pub baseline: Option<PathBuf>,
126 pub diff: Option<String>,
127 pub package: Option<String>,
128 pub deny: Severity,
129 pub strict: bool,
130 pub no_llm: bool,
131 pub blame: bool,
132 pub fix: bool,
133}
134
135impl Default for RunConfig {
136 fn default() -> Self {
137 Self {
138 root: PathBuf::from("."),
139 pillar: Pillar::All,
140 format: "human".into(),
141 fix_prompt: false,
142 config: None,
143 baseline: None,
144 diff: None,
145 package: None,
146 deny: Severity::Notice,
147 strict: false,
148 no_llm: false,
149 blame: false,
150 fix: false,
151 }
152 }
153}
154
155pub fn run_cli(cfg: &RunConfig) -> anyhow::Result<ExitCode> {
162 let root = cfg.root.canonicalize()?;
163
164 let (config_path, source) = if let Some(ref p) = cfg.config {
165 (p.clone(), ConfigSource::Explicit)
166 } else {
167 let p = Config::discover(&root).unwrap_or_else(|| root.join("spec-drift.toml"));
168 (p, ConfigSource::Discovered)
169 };
170 let config = Config::load(&config_path, source)?;
171
172 let mut files = sources::FsWalker::walk(&root)?;
173 let changed_files = if let Some(ref reference) = cfg.diff {
174 match sources::GitHistory::changed_files(&root, reference) {
175 Some(changed) => Some(changed),
176 None => {
177 eprintln!(
178 "spec-drift: --diff {reference}: git unavailable or ref unknown; \
179 scanning full tree."
180 );
181 None
182 }
183 }
184 } else {
185 None
186 };
187
188 let mut analysis_root = root.clone();
189 if let Some(ref name) = cfg.package {
190 let packages = workspace::load(&root);
191 let pkg = workspace::find(&packages, name).map_err(anyhow::Error::msg)?;
192 analysis_root = pkg.root.clone();
193 files.rust = workspace::narrow_paths(files.rust, pkg);
194 files.markdown = workspace::narrow_paths(files.markdown, pkg);
195 files.yaml = workspace::narrow_paths(files.yaml, pkg);
196 files.makefiles = workspace::narrow_paths(files.makefiles, pkg);
197 }
198
199 let diff_scope = changed_files.map(|changed| {
200 let changed = if cfg.package.is_some() {
201 changed
202 .into_iter()
203 .filter(|p| p.starts_with(&analysis_root))
204 .collect()
205 } else {
206 changed
207 };
208 DiffScope::new(&root, changed)
209 });
210
211 let mut ctx = ProjectContext::new(&root);
212 ctx.analysis_root = analysis_root.clone();
213 ctx.rust_files = files.rust.clone();
214 ctx.markdown_files = files.markdown.clone();
215 ctx.yaml_files = files.yaml.clone();
216 ctx.makefile_files = files.makefiles.clone();
217
218 for rs in &ctx.rust_files {
219 match parsers::RustParser::parse(rs) {
220 Ok(facts) => ctx.code_facts.extend(facts),
221 Err(e) => eprintln!("spec-drift: skipping {}: {e}", rs.display()),
222 }
223 }
224
225 let analyzers = build_analyzers(cfg.pillar, &config, cfg.no_llm);
226 let mut divergences = run(&ctx, &analyzers);
227 if let Some(scope) = diff_scope.as_ref() {
228 divergences = filter_to_diff_scope(divergences, &root, scope);
229 }
230 divergences = apply_config(divergences, &config, &root);
231 divergences = suppress::apply_inline_ignores(divergences);
232 divergences = normalize_locations(divergences, &root);
233
234 if let Some(ref baseline_path) = cfg.baseline {
235 let baseline = baseline::load(baseline_path)?;
236 divergences = baseline::subtract(divergences, &baseline);
237 }
238
239 if cfg.strict {
240 divergences = apply_strict(divergences);
241 }
242
243 if cfg.blame {
244 divergences = blame::apply(divergences, &root, &blame::GitBlameEngine);
245 }
246
247 if cfg.fix {
248 let applied = auto_fix::apply_fixes(&divergences, &root);
249 eprintln!("spec-drift: applied {applied} auto-fix(es)");
250
251 let llm_client = llm::build_client(&config.llm, cfg.no_llm);
252 let nd_applied = apply_nondeterministic_fixes(
253 &root,
254 &analysis_root,
255 &files,
256 cfg.pillar,
257 &config,
258 cfg.no_llm,
259 &divergences,
260 &llm_client,
261 &ctx,
262 )?;
263 if nd_applied > 0 {
264 eprintln!("spec-drift: applied {nd_applied} coherency auto-correction(s)");
265 }
266 }
267
268 let rendered = if cfg.fix_prompt {
269 reporters::FixPromptReporter.render(&divergences)
270 } else {
271 match cfg.format.as_str() {
272 "json" => reporters::JsonReporter.render(&divergences),
273 "sarif" => reporters::SarifReporter.render(&divergences),
274 _ => reporters::HumanReporter.render(&divergences),
275 }
276 };
277 print!("{rendered}");
278
279 let has_blocking = divergences.iter().any(|d| d.severity >= cfg.deny);
280 if has_blocking {
281 Ok(ExitCode::from(1))
282 } else {
283 Ok(ExitCode::SUCCESS)
284 }
285}
286
287struct DiffScope {
288 changed: HashSet<PathBuf>,
289 implementation_changed: bool,
290 cargo_metadata_changed: bool,
291}
292
293impl DiffScope {
294 fn new(root: &Path, changed: HashSet<PathBuf>) -> Self {
295 let implementation_changed = changed.iter().any(|p| is_rust_implementation_path(root, p));
296 let cargo_metadata_changed = changed.iter().any(|p| is_cargo_manifest_path(p));
297
298 Self {
299 changed,
300 implementation_changed,
301 cargo_metadata_changed,
302 }
303 }
304}
305
306fn filter_to_diff_scope(divs: Vec<Divergence>, root: &Path, scope: &DiffScope) -> Vec<Divergence> {
307 divs.into_iter()
308 .filter(|d| {
309 divergence_is_in_changed_file(d, root, &scope.changed)
310 || could_be_diff_induced(d, scope)
311 })
312 .collect()
313}
314
315fn divergence_is_in_changed_file(d: &Divergence, root: &Path, changed: &HashSet<PathBuf>) -> bool {
316 if changed.contains(&d.location.file) {
317 return true;
318 }
319 if d.location.file.is_relative() {
320 return changed.contains(&root.join(&d.location.file));
321 }
322 if let Ok(rel) = d.location.file.strip_prefix(root) {
323 return changed.contains(rel);
324 }
325 false
326}
327
328fn could_be_diff_induced(d: &Divergence, scope: &DiffScope) -> bool {
329 match d.rule {
330 RuleId::SymbolAbsence
331 | RuleId::CompileFailure
332 | RuleId::DeprecatedUsage
333 | RuleId::OutdatedLogic
334 | RuleId::LogicGap => scope.implementation_changed || scope.cargo_metadata_changed,
335 RuleId::ConstraintViolation => scope.implementation_changed,
336 RuleId::GhostCommand => scope.cargo_metadata_changed,
337 RuleId::MissingCoverage | RuleId::LyingTest | RuleId::EnvMismatch => false,
338 }
339}
340
341fn is_rust_implementation_path(root: &Path, path: &Path) -> bool {
342 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
343 return false;
344 }
345
346 let rel = path.strip_prefix(root).unwrap_or(path);
347 !rel.components()
348 .any(|c| c.as_os_str() == "tests" || c.as_os_str() == "examples")
349}
350
351fn is_cargo_manifest_path(path: &Path) -> bool {
352 path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml")
353}
354
355fn build_analyzers(pillar: Pillar, config: &Config, no_llm: bool) -> Vec<Box<dyn DriftAnalyzer>> {
358 let docs = matches!(pillar, Pillar::All | Pillar::Docs);
359 let examples = matches!(pillar, Pillar::All | Pillar::Examples);
360 let tests = matches!(pillar, Pillar::All | Pillar::Tests);
361 let ci = matches!(pillar, Pillar::All | Pillar::Ci);
362
363 let llm_client = llm::build_client(&config.llm, no_llm);
364
365 let mut v: Vec<Box<dyn DriftAnalyzer>> = Vec::new();
366 if docs {
367 v.push(Box::new(analyzers::DocsAnalyzer::default()));
368 v.push(Box::new(analyzers::MissingCoverageAnalyzer));
369 if !config.constraint_rules.is_empty() {
370 v.push(Box::new(analyzers::ConstraintAnalyzer::new(
371 config.constraint_rules.clone(),
372 )));
373 }
374 v.push(Box::new(analyzers::OutdatedLogicAnalyzer::new(
375 llm_client.clone(),
376 )));
377 }
378 if examples {
379 v.push(Box::new(analyzers::ExamplesAnalyzer::default()));
380 v.push(Box::new(analyzers::DeprecatedUsageAnalyzer::default()));
381 v.push(Box::new(analyzers::LogicGapAnalyzer::new(
382 llm_client.clone(),
383 )));
384 }
385 if tests {
386 v.push(Box::new(analyzers::TestsAnalyzer));
387 }
388 if ci {
389 v.push(Box::new(analyzers::CiAnalyzer::default()));
390 v.push(Box::new(analyzers::EnvMismatchAnalyzer));
391 }
392 v
393}
394
395#[allow(clippy::too_many_arguments)]
396fn apply_nondeterministic_fixes(
397 root: &Path,
398 analysis_root: &Path,
399 files: &sources::DiscoveredFiles,
400 pillar: Pillar,
401 config: &Config,
402 no_llm: bool,
403 divergences: &[Divergence],
404 llm_client: &std::sync::Arc<dyn llm::LlmClient>,
405 ctx: &ProjectContext,
406) -> anyhow::Result<u32> {
407 let mut applied = 0;
408
409 for d in divergences {
410 if d.rule == RuleId::OutdatedLogic {
411 let abs_path = root.join(&d.location.file);
412 if let Some((original_section, range)) =
413 auto_fix::slice_markdown_section(&abs_path, d.location.line)
414 {
415 let code_context = auto_fix::build_outdated_logic_context(ctx, &original_section);
416 let (system, user) = auto_fix::build_markdown_correction_prompt(
417 &original_section,
418 &code_context,
419 &d.reality,
420 );
421
422 eprintln!(
423 " Coherency Auto-Correction (OutdatedLogic): {}",
424 d.location.file.display()
425 );
426 if let Some(corrected) = llm_client.complete(&system, &user) {
427 let corrected = corrected.trim().to_string();
428 if !corrected.is_empty() {
429 let original_content = std::fs::read_to_string(&abs_path)?;
430 let mut new_content = original_content.clone();
431 new_content.replace_range(range.clone(), &corrected);
432
433 std::fs::write(&abs_path, &new_content)?;
435
436 if run_validation_sweep(root, analysis_root, files, pillar, config, no_llm)
438 {
439 eprintln!(" [OK] Passed validation!");
440 applied += 1;
441 } else {
442 eprintln!(
443 " [ROLLBACK] Validation failed (compile error or critical regression). Rolling back..."
444 );
445 std::fs::write(&abs_path, &original_content)?;
446 }
447 }
448 }
449 }
450 } else if d.rule == RuleId::LogicGap {
451 let abs_path = root.join(&d.location.file);
452 if let Some((original_narrative, range)) = auto_fix::slice_example_narrative(&abs_path)
453 {
454 let public_signatures = auto_fix::collect_public_signatures(ctx);
455 let (system, user) = auto_fix::build_example_narrative_prompt(
456 &original_narrative,
457 &public_signatures,
458 &d.reality,
459 );
460
461 eprintln!(
462 " Coherency Auto-Correction (LogicGap): {}",
463 d.location.file.display()
464 );
465 if let Some(corrected_narrative) = llm_client.complete(&system, &user) {
466 let original_content = std::fs::read_to_string(&abs_path)?;
467 let first_line = original_content[range.clone()].lines().next().unwrap_or("");
468 let prefix = if first_line.trim_start().starts_with("//!") {
469 "//!"
470 } else {
471 "//"
472 };
473
474 let formatted_comments =
475 auto_fix::format_as_comments(&corrected_narrative, prefix);
476 let mut new_content = original_content.clone();
477 new_content.replace_range(range.clone(), &formatted_comments);
478
479 std::fs::write(&abs_path, &new_content)?;
481
482 if run_validation_sweep(root, analysis_root, files, pillar, config, no_llm) {
484 eprintln!(" [OK] Passed validation!");
485 applied += 1;
486 } else {
487 eprintln!(
488 " [ROLLBACK] Validation failed (compile error or critical regression). Rolling back..."
489 );
490 std::fs::write(&abs_path, &original_content)?;
491 }
492 }
493 }
494 }
495 }
496
497 Ok(applied)
498}
499
500fn run_validation_sweep(
501 root: &Path,
502 analysis_root: &Path,
503 files: &sources::DiscoveredFiles,
504 pillar: Pillar,
505 config: &Config,
506 no_llm: bool,
507) -> bool {
508 let mut val_ctx = ProjectContext::new(root);
509 val_ctx.analysis_root = analysis_root.to_path_buf();
510 val_ctx.rust_files = files.rust.clone();
511 val_ctx.markdown_files = files.markdown.clone();
512 val_ctx.yaml_files = files.yaml.clone();
513 val_ctx.makefile_files = files.makefiles.clone();
514
515 for rs in &val_ctx.rust_files {
516 if let Ok(facts) = parsers::RustParser::parse(rs) {
517 val_ctx.code_facts.extend(facts);
518 }
519 }
520
521 let analyzers = build_analyzers(pillar, config, no_llm);
522 let divergences = run(&val_ctx, &analyzers);
523 let divergences = apply_config(divergences, config, root);
524 let divergences = suppress::apply_inline_ignores(divergences);
525
526 for d in &divergences {
527 if d.rule == RuleId::CompileFailure || d.severity == Severity::Critical {
528 return false;
529 }
530 }
531 true
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::domain::{Location, RuleId};
538
539 fn div(path: &str) -> Divergence {
540 Divergence {
541 rule: RuleId::SymbolAbsence,
542 severity: Severity::Critical,
543 location: Location::new(path, 1),
544 stated: "x".into(),
545 reality: "y".into(),
546 risk: "z".into(),
547 attribution: None,
548 }
549 }
550
551 #[test]
552 fn diff_filter_matches_relative_locations_against_changed_absolute_paths() {
553 let root = Path::new("/repo");
554 let scope = DiffScope::new(root, HashSet::from([PathBuf::from("/repo/README.md")]));
555
556 let out = filter_to_diff_scope(vec![div("README.md"), div("src/lib.rs")], root, &scope);
557
558 assert_eq!(out.len(), 1);
559 assert_eq!(out[0].location.file, PathBuf::from("README.md"));
560 }
561
562 #[test]
563 fn diff_filter_keeps_doc_drift_when_implementation_changed() {
564 let root = Path::new("/repo");
565 let scope = DiffScope::new(root, HashSet::from([PathBuf::from("/repo/src/lib.rs")]));
566
567 let out = filter_to_diff_scope(vec![div("README.md")], root, &scope);
568
569 assert_eq!(out.len(), 1);
570 assert_eq!(out[0].rule, RuleId::SymbolAbsence);
571 }
572
573 #[test]
574 fn diff_filter_keeps_ci_drift_when_cargo_manifest_changed() {
575 let root = Path::new("/repo");
576 let scope = DiffScope::new(root, HashSet::from([PathBuf::from("/repo/Cargo.toml")]));
577 let ghost = Divergence {
578 rule: RuleId::GhostCommand,
579 location: Location::new(".github/workflows/ci.yml", 10),
580 ..div(".github/workflows/ci.yml")
581 };
582
583 let out = filter_to_diff_scope(vec![ghost], root, &scope);
584
585 assert_eq!(out.len(), 1);
586 assert_eq!(out[0].rule, RuleId::GhostCommand);
587 }
588
589 #[test]
590 fn normalize_locations_strips_workspace_root() {
591 let root = Path::new("/repo");
592 let out = normalize_locations(
593 vec![div("/repo/README.md"), div("/elsewhere/file.md")],
594 root,
595 );
596
597 assert_eq!(out[0].location.file, PathBuf::from("README.md"));
598 assert_eq!(out[1].location.file, PathBuf::from("/elsewhere/file.md"));
599 }
600
601 struct MockLlmClient {
602 complete_val: String,
603 }
604 impl llm::LlmClient for MockLlmClient {
605 fn evaluate(&self, _: &str, _: &str) -> Option<crate::llm::LlmVerdict> {
606 None
607 }
608 fn complete(&self, _: &str, _: &str) -> Option<String> {
609 Some(self.complete_val.clone())
610 }
611 }
612
613 #[test]
614 fn test_nondeterministic_fixes_transaction_success_and_rollback() {
615 let tmp = tempfile::tempdir().unwrap();
616 let root = tmp.path();
617
618 let readme_path = root.join("README.md");
619 std::fs::write(
620 &readme_path,
621 "## Section 1\nUse `old_function()` to start.\n",
622 )
623 .unwrap();
624
625 let lib_path = root.join("src/lib.rs");
626 std::fs::create_dir_all(root.join("src")).unwrap();
627 std::fs::write(&lib_path, "pub fn new_function() {}\n").unwrap();
628
629 std::fs::write(
630 root.join("Cargo.toml"),
631 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
632 )
633 .unwrap();
634
635 let files = sources::DiscoveredFiles {
636 rust: vec![lib_path.clone()],
637 markdown: vec![readme_path.clone()],
638 yaml: vec![],
639 makefiles: vec![],
640 };
641
642 let mut ctx = ProjectContext::new(root);
643 ctx.analysis_root = root.to_path_buf();
644 ctx.rust_files = vec![lib_path.clone()];
645 ctx.markdown_files = vec![readme_path.clone()];
646 ctx.code_facts = crate::parsers::RustParser::parse(&lib_path).unwrap();
647
648 let divergences = vec![Divergence {
649 rule: RuleId::OutdatedLogic,
650 severity: Severity::Notice,
651 location: Location::new("README.md", 1),
652 stated: "section describes current behavior".into(),
653 reality: "Outdated!".into(),
654 risk: "Docs teach behavior the code no longer implements.".into(),
655 attribution: None,
656 }];
657
658 let mock_client_success: std::sync::Arc<dyn llm::LlmClient> =
659 std::sync::Arc::new(MockLlmClient {
660 complete_val: "## Section 1\nUse `new_function()` to start.\n".to_string(),
661 });
662
663 let config = Config::default();
664
665 let applied_success = apply_nondeterministic_fixes(
666 root,
667 root,
668 &files,
669 Pillar::Docs,
670 &config,
671 false,
672 &divergences,
673 &mock_client_success,
674 &ctx,
675 )
676 .unwrap();
677
678 assert_eq!(applied_success, 1);
679 let content_success = std::fs::read_to_string(&readme_path).unwrap();
680 assert!(content_success.contains("new_function"));
681
682 std::fs::write(
683 &readme_path,
684 "## Section 1\nUse `old_function()` to start.\n",
685 )
686 .unwrap();
687
688 let mock_client_fail: std::sync::Arc<dyn llm::LlmClient> =
689 std::sync::Arc::new(MockLlmClient {
690 complete_val: "## Section 1\nUse `missing_function()` to start.\n".to_string(),
691 });
692
693 let applied_fail = apply_nondeterministic_fixes(
694 root,
695 root,
696 &files,
697 Pillar::Docs,
698 &config,
699 false,
700 &divergences,
701 &mock_client_fail,
702 &ctx,
703 )
704 .unwrap();
705
706 assert_eq!(applied_fail, 0);
707 let content_fail = std::fs::read_to_string(&readme_path).unwrap();
708 assert!(content_fail.contains("old_function"));
709 assert!(!content_fail.contains("missing_function"));
710 }
711}