Skip to main content

infigraph_core/check/
mod.rs

1//! CI check runner — configurable security, complexity, and dead-code gates.
2//!
3//! Load thresholds from `.infigraph/check.toml` (with sane defaults), run the
4//! enabled checks, and return per-check PASS/FAIL results suitable for CI exit
5//! codes and human-readable or JSON output.
6
7use std::path::Path;
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11
12use crate::graph::store::GraphStore;
13use crate::graph::GraphQuery;
14use crate::security;
15
16// ---------------------------------------------------------------------------
17// Config model
18// ---------------------------------------------------------------------------
19
20/// Top-level config loaded from `.infigraph/check.toml`.
21#[derive(Debug, Clone, Default, Deserialize)]
22#[serde(default)]
23pub struct CheckConfig {
24    pub security: SecurityConfig,
25    pub complexity: ComplexityConfig,
26    pub dead_code: DeadCodeConfig,
27    pub vulnerabilities: VulnCheckConfig,
28}
29
30#[derive(Debug, Clone, Default, Deserialize)]
31#[serde(default)]
32pub struct VulnCheckConfig {
33    pub enabled: bool,
34    pub max_critical: usize,
35    pub max_high: usize,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39#[serde(default)]
40pub struct SecurityConfig {
41    pub enabled: bool,
42    pub max_critical: usize,
43    pub max_high: usize,
44}
45
46impl Default for SecurityConfig {
47    fn default() -> Self {
48        Self {
49            enabled: true,
50            max_critical: 0,
51            max_high: 0,
52        }
53    }
54}
55
56#[derive(Debug, Clone, Deserialize)]
57#[serde(default)]
58pub struct ComplexityConfig {
59    pub enabled: bool,
60    pub threshold: u32,
61    pub max_violations: usize,
62}
63
64impl Default for ComplexityConfig {
65    fn default() -> Self {
66        Self {
67            enabled: true,
68            threshold: 15,
69            max_violations: 0,
70        }
71    }
72}
73
74#[derive(Debug, Clone, Deserialize)]
75#[serde(default)]
76pub struct DeadCodeConfig {
77    pub enabled: bool,
78    pub max_dead: usize,
79    pub ignore_patterns: Vec<String>,
80}
81
82impl Default for DeadCodeConfig {
83    fn default() -> Self {
84        Self {
85            enabled: true,
86            max_dead: 50,
87            ignore_patterns: vec![
88                "main".into(),
89                "__init__".into(),
90                "setUp".into(),
91                "tearDown".into(),
92                "Java_*".into(),
93                "test_*".into(),
94                "Test*".into(),
95            ],
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// Result model
102// ---------------------------------------------------------------------------
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
105pub enum CheckStatus {
106    Pass,
107    Fail,
108}
109
110impl std::fmt::Display for CheckStatus {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            CheckStatus::Pass => write!(f, "PASS"),
114            CheckStatus::Fail => write!(f, "FAIL"),
115        }
116    }
117}
118
119/// A single named check result with summary details.
120#[derive(Debug, Clone, Serialize)]
121pub struct CheckResult {
122    pub name: String,
123    pub status: CheckStatus,
124    pub summary: String,
125    /// Human-readable detail lines (e.g. list of violations).
126    #[serde(skip_serializing_if = "Vec::is_empty")]
127    pub details: Vec<String>,
128}
129
130// ---------------------------------------------------------------------------
131// Config loading
132// ---------------------------------------------------------------------------
133
134/// Load check config from a TOML file path.
135/// Falls back to defaults if the file is missing.
136pub fn load_config(config_path: &Path) -> Result<CheckConfig> {
137    if config_path.exists() {
138        let text = std::fs::read_to_string(config_path)?;
139        let cfg: CheckConfig = toml::from_str(&text)?;
140        Ok(cfg)
141    } else {
142        Ok(CheckConfig::default())
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Check selection
148// ---------------------------------------------------------------------------
149
150/// Which checks to run.
151#[derive(Debug, Clone)]
152pub struct CheckSelection {
153    pub security: bool,
154    pub complexity: bool,
155    pub dead_code: bool,
156    pub vulnerabilities: bool,
157}
158
159impl CheckSelection {
160    /// All checks enabled.
161    pub fn all() -> Self {
162        Self {
163            security: true,
164            complexity: true,
165            dead_code: true,
166            vulnerabilities: true,
167        }
168    }
169
170    /// Parse a comma-separated list like "security,complexity,dead-code,vulns".
171    pub fn from_csv(s: &str) -> Self {
172        let mut sel = Self {
173            security: false,
174            complexity: false,
175            dead_code: false,
176            vulnerabilities: false,
177        };
178        for part in s.split(',') {
179            match part.trim().to_lowercase().as_str() {
180                "security" | "sec" => sel.security = true,
181                "complexity" | "cx" => sel.complexity = true,
182                "dead-code" | "dead_code" | "deadcode" => sel.dead_code = true,
183                "vulnerabilities" | "vulns" | "vuln" => sel.vulnerabilities = true,
184                _ => {}
185            }
186        }
187        sel
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Check runners
193// ---------------------------------------------------------------------------
194
195/// Run the selected checks against a project root and return results.
196pub fn run_checks(
197    root: &Path,
198    config: &CheckConfig,
199    store: &GraphStore,
200    selection: &CheckSelection,
201) -> Vec<CheckResult> {
202    let mut results = Vec::new();
203
204    if selection.security && config.security.enabled {
205        results.push(run_security_check(root, &config.security));
206    }
207
208    // Graph-based checks need a connection.
209    let conn_result = store.connection();
210    let conn = match conn_result {
211        Ok(ref c) => Some(c),
212        Err(ref e) => {
213            if selection.complexity && config.complexity.enabled {
214                results.push(CheckResult {
215                    name: "complexity".into(),
216                    status: CheckStatus::Fail,
217                    summary: format!("Graph connection failed: {e}"),
218                    details: vec![],
219                });
220            }
221            if selection.dead_code && config.dead_code.enabled {
222                results.push(CheckResult {
223                    name: "dead-code".into(),
224                    status: CheckStatus::Fail,
225                    summary: format!("Graph connection failed: {e}"),
226                    details: vec![],
227                });
228            }
229            None
230        }
231    };
232
233    if let Some(conn) = conn {
234        let gq = GraphQuery::new(conn);
235
236        if selection.complexity && config.complexity.enabled {
237            results.push(run_complexity_check(&gq, &config.complexity));
238        }
239        if selection.dead_code && config.dead_code.enabled {
240            results.push(run_dead_code_check(&gq, &config.dead_code));
241        }
242
243        if selection.vulnerabilities && config.vulnerabilities.enabled {
244            results.push(run_vuln_check(store, &config.vulnerabilities));
245        }
246    }
247
248    results
249}
250
251fn run_security_check(root: &Path, cfg: &SecurityConfig) -> CheckResult {
252    let canonical = match root.canonicalize() {
253        Ok(p) => p,
254        Err(e) => {
255            return CheckResult {
256                name: "security".into(),
257                status: CheckStatus::Fail,
258                summary: format!("Failed to resolve project root: {e}"),
259                details: vec![],
260            };
261        }
262    };
263
264    let scan = match security::scan_project(&canonical) {
265        Ok(s) => s,
266        Err(e) => {
267            return CheckResult {
268                name: "security".into(),
269                status: CheckStatus::Fail,
270                summary: format!("Security scan failed: {e}"),
271                details: vec![],
272            };
273        }
274    };
275
276    let critical = scan.critical_count();
277    let high = scan.high_count();
278    let medium = scan.medium_count();
279    let low = scan.low_count();
280
281    let failed = critical > cfg.max_critical || high > cfg.max_high;
282
283    let mut details = Vec::new();
284    if failed {
285        for f in scan.findings.iter().take(20) {
286            if f.severity == security::Severity::Critical || f.severity == security::Severity::High
287            {
288                details.push(format!(
289                    "  [{sev}] {file}:{line} -- {msg}",
290                    sev = f.severity,
291                    file = f.file,
292                    line = f.line,
293                    msg = f.message,
294                ));
295            }
296        }
297    }
298
299    CheckResult {
300        name: "security".into(),
301        status: if failed {
302            CheckStatus::Fail
303        } else {
304            CheckStatus::Pass
305        },
306        summary: format!(
307            "{critical} critical, {high} high, {medium} medium, {low} low \
308             (max_critical={}, max_high={})",
309            cfg.max_critical, cfg.max_high,
310        ),
311        details,
312    }
313}
314
315fn run_complexity_check(gq: &GraphQuery, cfg: &ComplexityConfig) -> CheckResult {
316    let query = format!(
317        "MATCH (s:Symbol) WHERE s.complexity >= {} \
318         AND (s.kind = 'Function' OR s.kind = 'Method') \
319         RETURN s.name, s.file, s.complexity ORDER BY s.complexity DESC",
320        cfg.threshold,
321    );
322
323    let rows = match gq.raw_query(&query) {
324        Ok(r) => r,
325        Err(e) => {
326            return CheckResult {
327                name: "complexity".into(),
328                status: CheckStatus::Fail,
329                summary: format!("Query failed: {e}"),
330                details: vec![],
331            };
332        }
333    };
334
335    let count = rows.len();
336    let failed = count > cfg.max_violations;
337
338    let details: Vec<String> = if failed {
339        rows.iter()
340            .take(20)
341            .filter_map(|row| {
342                let name = row.first()?;
343                let file = row.get(1)?;
344                let cplx = row.get(2)?;
345                Some(format!("  [{cplx:>3}] {name}  ({file})"))
346            })
347            .collect()
348    } else {
349        vec![]
350    };
351
352    CheckResult {
353        name: "complexity".into(),
354        status: if failed {
355            CheckStatus::Fail
356        } else {
357            CheckStatus::Pass
358        },
359        summary: format!(
360            "{count} symbols >= threshold {threshold} (max_violations={max})",
361            threshold = cfg.threshold,
362            max = cfg.max_violations,
363        ),
364        details,
365    }
366}
367
368fn run_dead_code_check(gq: &GraphQuery, cfg: &DeadCodeConfig) -> CheckResult {
369    let query = "MATCH (s:Symbol) WHERE s.kind IN ['Function', 'Method'] \
370                 AND NOT EXISTS { MATCH ()-[:CALLS]->(s) } \
371                 AND NOT EXISTS { MATCH (p:Symbol)<-[:INHERITS]-() WHERE p.file = s.file AND p.kind IN ['Class', 'Interface', 'Trait'] } \
372                 RETURN s.name, s.kind, s.file ORDER BY s.file, s.name";
373
374    let rows = match gq.raw_query(query) {
375        Ok(r) => r,
376        Err(e) => {
377            return CheckResult {
378                name: "dead-code".into(),
379                status: CheckStatus::Fail,
380                summary: format!("Query failed: {e}"),
381                details: vec![],
382            };
383        }
384    };
385
386    // Filter out ignored patterns (supports exact match and prefix glob with trailing *)
387    let dead: Vec<&Vec<String>> = rows
388        .iter()
389        .filter(|row| {
390            let name = row.first().map(|s| s.as_str()).unwrap_or("");
391            !cfg.ignore_patterns.iter().any(|pat| {
392                if let Some(prefix) = pat.strip_suffix('*') {
393                    name.starts_with(prefix)
394                } else {
395                    name == pat
396                }
397            })
398        })
399        .collect();
400
401    let count = dead.len();
402    let failed = count > cfg.max_dead;
403
404    let details: Vec<String> = if failed {
405        dead.iter()
406            .take(20)
407            .filter_map(|row| {
408                let name = row.first()?;
409                let kind = row.get(1)?;
410                let file = row.get(2)?;
411                Some(format!("  {kind:>8} {name}  ({file})"))
412            })
413            .collect()
414    } else {
415        vec![]
416    };
417
418    CheckResult {
419        name: "dead-code".into(),
420        status: if failed {
421            CheckStatus::Fail
422        } else {
423            CheckStatus::Pass
424        },
425        summary: format!("{count} dead symbols (max_dead={max})", max = cfg.max_dead),
426        details,
427    }
428}
429
430fn run_vuln_check(store: &GraphStore, cfg: &VulnCheckConfig) -> CheckResult {
431    let deps = match crate::manifest::query_deps(store) {
432        Ok(d) => d,
433        Err(e) => {
434            return CheckResult {
435                name: "vulns".into(),
436                status: CheckStatus::Pass,
437                summary: format!("failed to query deps: {e}"),
438                details: vec![],
439            };
440        }
441    };
442    if deps.is_empty() {
443        return CheckResult {
444            name: "vulns".into(),
445            status: CheckStatus::Pass,
446            summary: "no dependencies indexed (run infigraph index-manifests first)".into(),
447            details: vec![],
448        };
449    }
450
451    let report = match crate::vuln::scan_deps(&deps) {
452        Ok(r) => r,
453        Err(e) => {
454            return CheckResult {
455                name: "vulns".into(),
456                status: CheckStatus::Pass,
457                summary: format!("scan skipped: {e}"),
458                details: vec![],
459            };
460        }
461    };
462
463    let critical = report
464        .findings
465        .iter()
466        .filter(|f| f.severity == "CRITICAL")
467        .count();
468    let high = report
469        .findings
470        .iter()
471        .filter(|f| f.severity == "HIGH")
472        .count();
473    let medium = report
474        .findings
475        .iter()
476        .filter(|f| f.severity == "MEDIUM")
477        .count();
478    let low = report
479        .findings
480        .iter()
481        .filter(|f| f.severity == "LOW")
482        .count();
483
484    let failed = critical > cfg.max_critical || high > cfg.max_high;
485
486    let details: Vec<String> = if failed {
487        report
488            .findings
489            .iter()
490            .filter(|f| f.severity == "CRITICAL" || f.severity == "HIGH")
491            .take(20)
492            .map(|f| {
493                format!(
494                    "  [{}] {} {} -- {}",
495                    f.severity, f.dep_name, f.dep_version, f.summary
496                )
497            })
498            .collect()
499    } else {
500        vec![]
501    };
502
503    CheckResult {
504        name: "vulns".into(),
505        status: if failed { CheckStatus::Fail } else { CheckStatus::Pass },
506        summary: format!(
507            "{critical} critical, {high} high, {medium} medium, {low} low (max_critical={}, max_high={})",
508            cfg.max_critical, cfg.max_high,
509        ),
510        details,
511    }
512}
513
514// ---------------------------------------------------------------------------
515// Output formatting
516// ---------------------------------------------------------------------------
517
518/// Format results as a human-readable table.
519pub fn format_table(results: &[CheckResult]) -> String {
520    let mut out = String::new();
521
522    out.push_str("\n  Check         Status   Summary\n");
523    out.push_str("  ------------- ------   -------\n");
524
525    for r in results {
526        let status_str = match r.status {
527            CheckStatus::Pass => "PASS",
528            CheckStatus::Fail => "FAIL",
529        };
530        out.push_str(&format!(
531            "  {:<13} {:<8} {}\n",
532            r.name, status_str, r.summary
533        ));
534    }
535
536    // Print details for failures.
537    let failures: Vec<_> = results
538        .iter()
539        .filter(|r| r.status == CheckStatus::Fail)
540        .collect();
541    if !failures.is_empty() {
542        out.push('\n');
543        for r in &failures {
544            if !r.details.is_empty() {
545                out.push_str(&format!("  {} details:\n", r.name));
546                for d in &r.details {
547                    out.push_str(&format!("{d}\n"));
548                }
549                out.push('\n');
550            }
551        }
552    }
553
554    let total = results.len();
555    let passed = results
556        .iter()
557        .filter(|r| r.status == CheckStatus::Pass)
558        .count();
559    let failed_count = total - passed;
560
561    out.push_str(&format!("\n  {passed}/{total} checks passed"));
562    if failed_count > 0 {
563        out.push_str(&format!(", {failed_count} failed"));
564    }
565    out.push('\n');
566
567    out
568}
569
570/// Format results as JSON.
571pub fn format_json(results: &[CheckResult]) -> String {
572    serde_json::to_string_pretty(results).unwrap_or_else(|_| "[]".to_string())
573}