Skip to main content

batuta/stack/releaser_preflight/
checks.rs

1//! Preflight check methods for ReleaseOrchestrator
2//!
3//! Contains all check_* methods for various quality gates.
4
5use crate::stack::releaser::ReleaseOrchestrator;
6use crate::stack::types::PreflightCheck;
7use std::path::Path;
8use std::process::Command;
9
10use super::helpers::{
11    parse_count_from_json_multi, parse_value_from_json, run_check_command, score_check_result,
12};
13
14impl ReleaseOrchestrator {
15    /// Check if git working directory is clean
16    pub(in crate::stack) fn check_git_clean(&self, crate_path: &Path) -> PreflightCheck {
17        let output =
18            Command::new("git").args(["status", "--porcelain"]).current_dir(crate_path).output();
19
20        match output {
21            Ok(out) => {
22                if out.stdout.is_empty() {
23                    PreflightCheck::pass("git_clean", "Working directory is clean")
24                } else {
25                    let files = String::from_utf8_lossy(&out.stdout);
26                    PreflightCheck::fail(
27                        "git_clean",
28                        format!("Uncommitted changes:\n{}", files.trim()),
29                    )
30                }
31            }
32            Err(e) => {
33                PreflightCheck::fail("git_clean", format!("Failed to check git status: {}", e))
34            }
35        }
36    }
37
38    /// Check lint passes
39    pub(in crate::stack) fn check_lint(&self, crate_path: &Path) -> PreflightCheck {
40        run_check_command(
41            &self.config.lint_command,
42            "lint",
43            "No lint command configured",
44            crate_path,
45            |output, _stdout, stderr| {
46                if output.status.success() {
47                    PreflightCheck::pass("lint", "Lint passed")
48                } else {
49                    PreflightCheck::fail("lint", format!("Lint failed: {}", stderr.trim()))
50                }
51            },
52        )
53    }
54
55    /// Check coverage meets minimum
56    pub(in crate::stack) fn check_coverage(&self, crate_path: &Path) -> PreflightCheck {
57        let min_coverage = self.config.min_coverage;
58        run_check_command(
59            &self.config.coverage_command,
60            "coverage",
61            "No coverage command configured",
62            crate_path,
63            move |output, _stdout, _stderr| {
64                if output.status.success() {
65                    PreflightCheck::pass(
66                        "coverage",
67                        format!("Coverage check passed (min: {}%)", min_coverage),
68                    )
69                } else {
70                    PreflightCheck::fail("coverage", format!("Coverage below {}%", min_coverage))
71                }
72            },
73        )
74    }
75
76    /// Check PMAT comply for ComputeBrick defects (CB-XXX violations)
77    ///
78    /// Runs `pmat comply` to detect:
79    /// - CB-020: Unsafe blocks without safety comments
80    /// - CB-021: SIMD without target_feature attributes
81    /// - CB-022: Missing error handling patterns
82    /// - And other PMAT compliance rules
83    pub(in crate::stack) fn check_pmat_comply(&self, crate_path: &Path) -> PreflightCheck {
84        let fail_on_violations = self.config.fail_on_comply_violations;
85        run_check_command(
86            &self.config.comply_command,
87            "pmat_comply",
88            "No comply command configured (skipped)",
89            crate_path,
90            move |output, stdout, stderr| {
91                let has_violations = stdout.contains("CB-")
92                    || stderr.contains("CB-")
93                    || stdout.contains("violation")
94                    || stderr.contains("violation");
95
96                if output.status.success() && !has_violations {
97                    PreflightCheck::pass("pmat_comply", "PMAT comply passed (0 violations)")
98                } else if has_violations && fail_on_violations {
99                    let violation_hint = if stdout.contains("CB-") {
100                        stdout
101                            .lines()
102                            .filter(|l| l.contains("CB-"))
103                            .take(3)
104                            .collect::<Vec<_>>()
105                            .join("; ")
106                    } else {
107                        "violations detected".to_string()
108                    };
109                    PreflightCheck::fail(
110                        "pmat_comply",
111                        format!("PMAT comply failed: {}", violation_hint),
112                    )
113                } else if has_violations {
114                    PreflightCheck::pass("pmat_comply", "PMAT comply has warnings (not blocking)")
115                } else {
116                    PreflightCheck::fail(
117                        "pmat_comply",
118                        format!("PMAT comply error: {}", stderr.trim()),
119                    )
120                }
121            },
122        )
123    }
124
125    /// Check for path dependencies
126    pub(in crate::stack) fn check_no_path_deps(&self, _crate_name: &str) -> PreflightCheck {
127        // This would use the checker's graph to verify no path deps
128        // For now, always pass as a placeholder
129        PreflightCheck::pass("no_path_deps", "No path dependencies found")
130    }
131
132    /// Check version is bumped from crates.io
133    pub(in crate::stack) fn check_version_bumped(&self, _crate_name: &str) -> PreflightCheck {
134        // This would compare local version vs crates.io
135        // For now, always pass as a placeholder
136        PreflightCheck::pass("version_bumped", "Version is ahead of crates.io")
137    }
138
139    // =========================================================================
140    // PMAT Quality Gate Integration (PMAT-STACK-GATES)
141    // =========================================================================
142
143    /// Check PMAT quality-gate (comprehensive quality checks)
144    ///
145    /// Runs `pmat quality-gate` which includes:
146    /// - Dead code detection
147    /// - Complexity analysis
148    /// - Coverage verification
149    /// - SATD detection
150    /// - Security checks
151    pub(in crate::stack) fn check_pmat_quality_gate(&self, crate_path: &Path) -> PreflightCheck {
152        let fail_on_gate = self.config.fail_on_quality_gate;
153        run_check_command(
154            &self.config.quality_gate_command,
155            "quality_gate",
156            "No quality-gate command configured (skipped)",
157            crate_path,
158            move |output, _stdout, stderr| {
159                if output.status.success() {
160                    PreflightCheck::pass("quality_gate", "PMAT quality-gate passed")
161                } else if fail_on_gate {
162                    PreflightCheck::fail(
163                        "quality_gate",
164                        format!("Quality gate failed: {}", stderr.trim()),
165                    )
166                } else {
167                    PreflightCheck::pass("quality_gate", "Quality gate has warnings (not blocking)")
168                }
169            },
170        )
171    }
172
173    /// Check PMAT TDG (Technical Debt Grading) score
174    ///
175    /// Runs `pmat tdg --format json` and parses the score.
176    /// Fails if score < min_tdg_score (default: 80).
177    pub(in crate::stack) fn check_pmat_tdg(&self, crate_path: &Path) -> PreflightCheck {
178        let min_score = self.config.min_tdg_score;
179        let fail_on = self.config.fail_on_tdg;
180        run_check_command(
181            &self.config.tdg_command,
182            "tdg",
183            "No TDG command configured (skipped)",
184            crate_path,
185            move |output, stdout, _stderr| {
186                let score = parse_value_from_json(stdout, &["score", "tdg_score", "total"]);
187                score_check_result(
188                    "tdg",
189                    "TDG score",
190                    score,
191                    min_score,
192                    fail_on,
193                    output.status.success(),
194                )
195            },
196        )
197    }
198
199    /// Check PMAT dead-code analysis
200    ///
201    /// Runs `pmat analyze dead-code` to detect unused code.
202    pub(in crate::stack) fn check_pmat_dead_code(&self, crate_path: &Path) -> PreflightCheck {
203        let fail_on = self.config.fail_on_dead_code;
204        run_check_command(
205            &self.config.dead_code_command,
206            "dead_code",
207            "No dead-code command configured (skipped)",
208            crate_path,
209            move |_output, stdout, _stderr| {
210                let has_dead_code = stdout.contains("dead_code") || stdout.contains("unused");
211                let count = parse_count_from_json_multi(stdout, &["count", "dead_code_count"]);
212
213                match (has_dead_code, count) {
214                    (_, Some(0)) | (false, None) => {
215                        PreflightCheck::pass("dead_code", "No dead code detected")
216                    }
217                    (_, Some(n)) if fail_on => {
218                        PreflightCheck::fail("dead_code", format!("{} dead code items found", n))
219                    }
220                    (_, Some(n)) => PreflightCheck::pass(
221                        "dead_code",
222                        format!("{} dead code items (warning)", n),
223                    ),
224                    (true, None) if fail_on => {
225                        PreflightCheck::fail("dead_code", "Dead code detected")
226                    }
227                    (true, None) => {
228                        PreflightCheck::pass("dead_code", "Dead code detected (warning)")
229                    }
230                }
231            },
232        )
233    }
234
235    /// Check PMAT complexity analysis
236    ///
237    /// Runs `pmat analyze complexity` to check cyclomatic complexity.
238    /// Fails if any function exceeds max_complexity (default: 20).
239    pub(in crate::stack) fn check_pmat_complexity(&self, crate_path: &Path) -> PreflightCheck {
240        let max_complexity = self.config.max_complexity;
241        let fail_on = self.config.fail_on_complexity;
242        run_check_command(
243            &self.config.complexity_command,
244            "complexity",
245            "No complexity command configured (skipped)",
246            crate_path,
247            move |output, stdout, _stderr| {
248                let max_found = parse_count_from_json_multi(stdout, &["max_complexity", "highest"]);
249                let violations =
250                    parse_count_from_json_multi(stdout, &["violations", "violation_count"]);
251
252                match (max_found, violations) {
253                    (Some(m), _) if m <= max_complexity => PreflightCheck::pass(
254                        "complexity",
255                        format!("Max complexity: {} (limit: {})", m, max_complexity),
256                    ),
257                    (Some(m), _) if fail_on => PreflightCheck::fail(
258                        "complexity",
259                        format!("Complexity {} exceeds limit {}", m, max_complexity),
260                    ),
261                    (_, Some(0)) => PreflightCheck::pass("complexity", "No complexity violations"),
262                    (_, Some(v)) if fail_on => {
263                        PreflightCheck::fail("complexity", format!("{} complexity violations", v))
264                    }
265                    _ if output.status.success() => {
266                        PreflightCheck::pass("complexity", "Complexity check passed")
267                    }
268                    _ => PreflightCheck::pass("complexity", "Complexity check completed (warning)"),
269                }
270            },
271        )
272    }
273
274    /// Check PMAT SATD (Self-Admitted Technical Debt)
275    ///
276    /// Runs `pmat analyze satd` to detect TODO/FIXME/HACK comments.
277    /// Fails if count exceeds max_satd_items (default: 10).
278    pub(in crate::stack) fn check_pmat_satd(&self, crate_path: &Path) -> PreflightCheck {
279        let max_items = self.config.max_satd_items;
280        let fail_on = self.config.fail_on_satd;
281        run_check_command(
282            &self.config.satd_command,
283            "satd",
284            "No SATD command configured (skipped)",
285            crate_path,
286            move |output, stdout, _stderr| {
287                let count = parse_count_from_json_multi(stdout, &["total", "count", "satd_count"]);
288
289                match count {
290                    Some(c) if c <= max_items => PreflightCheck::pass(
291                        "satd",
292                        format!("{} SATD items (limit: {})", c, max_items),
293                    ),
294                    Some(c) if fail_on => PreflightCheck::fail(
295                        "satd",
296                        format!("{} SATD items exceed limit {}", c, max_items),
297                    ),
298                    Some(c) => PreflightCheck::pass(
299                        "satd",
300                        format!("{} SATD items (warning: exceeds {})", c, max_items),
301                    ),
302                    None if output.status.success() => {
303                        PreflightCheck::pass("satd", "SATD check passed")
304                    }
305                    None => PreflightCheck::pass("satd", "SATD check completed"),
306                }
307            },
308        )
309    }
310
311    /// Check PMAT Popper score (falsifiability)
312    ///
313    /// Runs `pmat popper-score` to assess scientific quality.
314    /// Based on Karl Popper's falsification principles.
315    /// Fails if score < min_popper_score (default: 60).
316    pub(in crate::stack) fn check_pmat_popper(&self, crate_path: &Path) -> PreflightCheck {
317        let min_score = self.config.min_popper_score;
318        let fail_on = self.config.fail_on_popper;
319        run_check_command(
320            &self.config.popper_command,
321            "popper",
322            "No Popper command configured (skipped)",
323            crate_path,
324            move |output, stdout, _stderr| {
325                let score = parse_value_from_json(stdout, &["score", "popper_score", "total"]);
326                score_check_result(
327                    "popper",
328                    "Popper score",
329                    score,
330                    min_score,
331                    fail_on,
332                    output.status.success(),
333                )
334            },
335        )
336    }
337
338    // =========================================================================
339    // Book and Examples Verification (RELEASE-DOCS)
340    // =========================================================================
341
342    /// Check book builds successfully
343    ///
344    /// Runs `mdbook build book` (or configured command) to verify
345    /// documentation compiles without errors.
346    pub(in crate::stack) fn check_book_build(&self, crate_path: &Path) -> PreflightCheck {
347        // Check if book directory exists before running the command
348        let book_dir = crate_path.join("book");
349        if !book_dir.exists() {
350            return PreflightCheck::pass("book", "No book directory found (skipped)");
351        }
352
353        let fail_on = self.config.fail_on_book;
354
355        run_check_command(
356            &self.config.book_command,
357            "book",
358            "No book command configured (skipped)",
359            crate_path,
360            move |output, _stdout, stderr| {
361                if output.status.success() {
362                    PreflightCheck::pass("book", "Book built successfully")
363                } else if fail_on {
364                    PreflightCheck::fail("book", format!("Book build failed: {}", stderr.trim()))
365                } else {
366                    PreflightCheck::pass("book", "Book build has warnings (not blocking)")
367                }
368            },
369        )
370    }
371
372    /// Check examples compile and run successfully
373    ///
374    /// Discovers examples from Cargo.toml [[example]] sections and
375    /// runs each one with `cargo run --example <name>`.
376    pub(in crate::stack) fn check_examples_run(&self, crate_path: &Path) -> PreflightCheck {
377        let parts: Vec<&str> = self.config.examples_command.split_whitespace().collect();
378        if parts.is_empty() {
379            return PreflightCheck::pass("examples", "No examples command configured (skipped)");
380        }
381
382        // Check if examples directory exists
383        let examples_dir = crate_path.join("examples");
384        if !examples_dir.exists() {
385            return PreflightCheck::pass("examples", "No examples directory found (skipped)");
386        }
387
388        // Discover examples from Cargo.toml or examples directory
389        let examples = self.discover_examples(crate_path);
390        if examples.is_empty() {
391            return PreflightCheck::pass("examples", "No examples found (skipped)");
392        }
393
394        let mut failed = Vec::new();
395        let mut succeeded = 0;
396
397        for example in &examples {
398            // Build the full command with example name
399            let output = Command::new("cargo")
400                .args(["run", "--example", example, "--", "--help"])
401                .current_dir(crate_path)
402                .output();
403
404            match output {
405                Ok(out) => {
406                    // Consider it a pass if the example compiles and runs
407                    // (even if --help exits with non-zero, compilation success is what matters)
408                    if out.status.success() || out.status.code() == Some(0) {
409                        succeeded += 1;
410                    } else {
411                        // Check if it failed during compilation vs runtime
412                        let stderr = String::from_utf8_lossy(&out.stderr);
413                        if stderr.contains("error[E") || stderr.contains("could not compile") {
414                            failed.push(example.clone());
415                        } else {
416                            // Runtime exit with non-zero is OK for --help
417                            succeeded += 1;
418                        }
419                    }
420                }
421                Err(_) => {
422                    failed.push(example.clone());
423                }
424            }
425        }
426
427        if failed.is_empty() {
428            PreflightCheck::pass(
429                "examples",
430                format!("{}/{} examples verified", succeeded, examples.len()),
431            )
432        } else if self.config.fail_on_examples {
433            PreflightCheck::fail(
434                "examples",
435                format!(
436                    "{}/{} examples failed: {}",
437                    failed.len(),
438                    examples.len(),
439                    failed.join(", ")
440                ),
441            )
442        } else {
443            PreflightCheck::pass(
444                "examples",
445                format!(
446                    "{}/{} examples verified ({} failed, not blocking)",
447                    succeeded,
448                    examples.len(),
449                    failed.len()
450                ),
451            )
452        }
453    }
454
455    /// Discover examples from the crate
456    pub(in crate::stack) fn discover_examples(&self, crate_path: &Path) -> Vec<String> {
457        let mut examples = Vec::new();
458
459        // Try to find examples from Cargo.toml
460        let cargo_toml = crate_path.join("Cargo.toml");
461        if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
462            // Simple parsing for [[example]] sections
463            for line in content.lines() {
464                if line.trim().starts_with("name = \"") {
465                    // Check if we're in an [[example]] section by looking at previous context
466                    // This is a simplified approach - in production, use toml crate
467                    if let Some(name) = line.split('"').nth(1) {
468                        // Verify it's actually in the examples dir
469                        let example_file = crate_path.join("examples").join(format!("{}.rs", name));
470                        if example_file.exists() {
471                            examples.push(name.to_string());
472                        }
473                    }
474                }
475            }
476        }
477
478        // Also scan examples directory for .rs files
479        let examples_dir = crate_path.join("examples");
480        if let Ok(entries) = std::fs::read_dir(&examples_dir) {
481            for entry in entries.flatten() {
482                let path = entry.path();
483                if path.extension().is_some_and(|e| e == "rs") {
484                    if let Some(stem) = path.file_stem() {
485                        let name = stem.to_string_lossy().to_string();
486                        if !examples.contains(&name) {
487                            examples.push(name);
488                        }
489                    }
490                }
491            }
492        }
493
494        examples
495    }
496
497    // =========================================================================
498    // JSON Parsing Helpers
499    // =========================================================================
500
501    /// Helper: Parse a numeric score from JSON output
502    pub(in crate::stack) fn parse_score_from_json(json: &str, key: &str) -> Option<f64> {
503        // Simple JSON parsing without serde for minimal dependencies
504        let pattern = format!("\"{}\":", key);
505        if let Some(pos) = json.find(&pattern) {
506            let after_key = &json[pos + pattern.len()..];
507            let value_str: String = after_key
508                .chars()
509                .skip_while(|c| c.is_whitespace())
510                .take_while(|c| c.is_numeric() || *c == '.' || *c == '-')
511                .collect();
512            value_str.parse().ok()
513        } else {
514            None
515        }
516    }
517
518    /// Helper: Parse an integer count from JSON output
519    pub(in crate::stack) fn parse_count_from_json(json: &str, key: &str) -> Option<u32> {
520        Self::parse_score_from_json(json, key).map(|f| f as u32)
521    }
522}