Skip to main content

batuta/falsification/
jidoka.rs

1//! Jidoka Automated Gates (Section 7)
2//!
3//! Implements JA-01 through JA-10 from the Popperian Falsification Checklist.
4//! Focus: CI/CD circuit breakers, automated quality enforcement.
5//!
6//! # Jidoka Principle
7//!
8//! "Automation with human intelligence" - The CI/CD pipeline must detect
9//! abnormalities and stop automatically before human review begins.
10
11use std::path::Path;
12use std::time::Instant;
13
14use super::helpers::{apply_check_outcome, CheckOutcome};
15use super::types::{CheckItem, CheckStatus, Evidence, EvidenceType, Severity};
16
17/// Scan source files matching a glob pattern for a content predicate.
18fn any_source_matches(project: &Path, glob_suffix: &str, pred: impl Fn(&str) -> bool) -> bool {
19    glob::glob(&format!("{}/{glob_suffix}", project.display()))
20        .ok()
21        .map(|entries| {
22            entries
23                .flatten()
24                .any(|p| std::fs::read_to_string(&p).ok().map(|c| pred(&c)).unwrap_or(false))
25        })
26        .unwrap_or(false)
27}
28
29/// Evaluate all Jidoka automated gates for a project.
30pub fn evaluate_all(project_path: &Path) -> Vec<CheckItem> {
31    vec![
32        check_precommit_hooks(project_path),
33        check_automated_sovereignty_linting(project_path),
34        check_data_drift_circuit_breaker(project_path),
35        check_performance_regression_gate(project_path),
36        check_fairness_metric_circuit_breaker(project_path),
37        check_latency_sla_circuit_breaker(project_path),
38        check_memory_footprint_gate(project_path),
39        check_security_scan_gate(project_path),
40        check_license_compliance_gate(project_path),
41        check_documentation_gate(project_path),
42    ]
43}
44
45/// JA-01: Pre-Commit Hook Enforcement
46///
47/// **Claim:** Pre-commit hooks catch basic issues locally.
48///
49/// **Rejection Criteria (Major):**
50/// - >5% of CI failures are pre-commit-detectable
51pub fn check_precommit_hooks(project_path: &Path) -> CheckItem {
52    let start = Instant::now();
53    let mut item = CheckItem::new(
54        "JA-01",
55        "Pre-Commit Hook Enforcement",
56        "Pre-commit hooks catch basic issues locally",
57    )
58    .with_severity(Severity::Major)
59    .with_tps("Jidoka — early detection");
60
61    // Check for pre-commit configuration
62    let precommit_yaml = project_path.join(".pre-commit-config.yaml");
63    let has_precommit = precommit_yaml.exists();
64
65    // Check for git hooks directory
66    let git_hooks = project_path.join(".git/hooks");
67    let has_git_hooks = git_hooks.exists()
68        && (git_hooks.join("pre-commit").exists() || git_hooks.join("pre-push").exists());
69
70    // Check for husky (Node.js)
71    let husky_dir = project_path.join(".husky");
72    let has_husky = husky_dir.exists();
73
74    // Check for cargo-husky or similar in Cargo.toml
75    let cargo_toml = project_path.join("Cargo.toml");
76    let has_cargo_hooks = cargo_toml
77        .exists()
78        .then(|| std::fs::read_to_string(&cargo_toml).ok())
79        .flatten()
80        .map(|c| c.contains("cargo-husky") || c.contains("[hooks]"))
81        .unwrap_or(false);
82
83    // Check for Makefile with pre-commit targets
84    let makefile = project_path.join("Makefile");
85    let has_make_hooks = makefile
86        .exists()
87        .then(|| std::fs::read_to_string(&makefile).ok())
88        .flatten()
89        .map(|c| c.contains("pre-commit") || c.contains("precommit") || c.contains("tier1"))
90        .unwrap_or(false);
91
92    item = item.with_evidence(Evidence {
93        evidence_type: EvidenceType::StaticAnalysis,
94        description: format!(
95            "Pre-commit: yaml={}, git_hooks={}, husky={}, cargo_hooks={}, make_targets={}",
96            has_precommit, has_git_hooks, has_husky, has_cargo_hooks, has_make_hooks
97        ),
98        data: None,
99        files: Vec::new(),
100    });
101
102    item = apply_check_outcome(
103        item,
104        &[
105            (has_precommit || has_cargo_hooks, CheckOutcome::Pass),
106            (
107                has_git_hooks || has_husky || has_make_hooks,
108                CheckOutcome::Partial("Pre-commit configured but not standardized"),
109            ),
110            (true, CheckOutcome::Fail("No pre-commit hooks configured")),
111        ],
112    );
113
114    item.finish_timed(start)
115}
116
117/// JA-02: Automated Sovereignty Linting
118///
119/// **Claim:** Static analysis catches sovereignty violations.
120///
121/// **Rejection Criteria (Major):**
122/// - Known violation pattern not flagged
123pub fn check_automated_sovereignty_linting(project_path: &Path) -> CheckItem {
124    let start = Instant::now();
125    let mut item = CheckItem::new(
126        "JA-02",
127        "Automated Sovereignty Linting",
128        "Static analysis catches sovereignty violations",
129    )
130    .with_severity(Severity::Major)
131    .with_tps("Jidoka — automated sovereignty check");
132
133    // Check for clippy configuration
134    let clippy_toml = project_path.join("clippy.toml");
135    let has_clippy_config = clippy_toml.exists();
136
137    // Check CI for clippy
138    let has_clippy_ci = check_ci_for_content(project_path, "clippy");
139
140    // Check for custom lints
141    let has_custom_lints = any_source_matches(project_path, "src/**/*.rs", |c| {
142        c.contains("#[deny(")
143            || c.contains("#![deny(")
144            || c.contains("#[warn(")
145            || c.contains("#![warn(")
146    });
147
148    // Check for deny.toml (cargo-deny)
149    let deny_toml = project_path.join("deny.toml");
150    let has_deny = deny_toml.exists();
151
152    item = item.with_evidence(Evidence {
153        evidence_type: EvidenceType::StaticAnalysis,
154        description: format!(
155            "Linting: clippy_config={}, clippy_ci={}, custom_lints={}, deny={}",
156            has_clippy_config, has_clippy_ci, has_custom_lints, has_deny
157        ),
158        data: None,
159        files: Vec::new(),
160    });
161
162    item = apply_check_outcome(
163        item,
164        &[
165            (has_clippy_ci && (has_deny || has_custom_lints), CheckOutcome::Pass),
166            (
167                has_clippy_ci,
168                CheckOutcome::Partial("Clippy in CI but limited sovereignty-specific rules"),
169            ),
170            (true, CheckOutcome::Fail("No automated linting in CI")),
171        ],
172    );
173
174    item.finish_timed(start)
175}
176
177/// JA-03: Data Drift Circuit Breaker
178///
179/// **Claim:** Training stops on significant data drift.
180///
181/// **Rejection Criteria (Major):**
182/// - Training completes with >20% distribution shift
183pub fn check_data_drift_circuit_breaker(project_path: &Path) -> CheckItem {
184    let start = Instant::now();
185    let mut item = CheckItem::new(
186        "JA-03",
187        "Data Drift Circuit Breaker",
188        "Training stops on significant data drift",
189    )
190    .with_severity(Severity::Major)
191    .with_tps("Jidoka — automatic stop");
192
193    // Check for drift detection patterns in code
194    let has_drift_detection = any_source_matches(project_path, "src/**/*.rs", |c| {
195        c.contains("drift")
196            || c.contains("distribution_shift")
197            || c.contains("data_quality")
198            || c.contains("schema_validation")
199    });
200
201    // Check for data validation in tests
202    let has_data_validation = any_source_matches(project_path, "tests/**/*.rs", |c| {
203        c.contains("data") && (c.contains("valid") || c.contains("schema"))
204    });
205
206    item = item.with_evidence(Evidence {
207        evidence_type: EvidenceType::StaticAnalysis,
208        description: format!(
209            "Data drift: detection={}, validation={}",
210            has_drift_detection, has_data_validation
211        ),
212        data: None,
213        files: Vec::new(),
214    });
215
216    // Check if project has ML training that needs drift detection
217    let has_training = any_source_matches(project_path, "src/**/*.rs", |c| {
218        c.contains("train") || c.contains("fit") || c.contains("epoch")
219    });
220
221    item = apply_check_outcome(
222        item,
223        &[
224            (!has_training || has_drift_detection || has_data_validation, CheckOutcome::Pass),
225            (true, CheckOutcome::Partial("Training without data drift detection")),
226        ],
227    );
228
229    item.finish_timed(start)
230}
231
232/// JA-04: Model Performance Regression Gate
233///
234/// **Claim:** Deployment blocked on performance regression.
235///
236/// **Rejection Criteria (Major):**
237/// - Model with <baseline metrics deploys
238pub fn check_performance_regression_gate(project_path: &Path) -> CheckItem {
239    let start = Instant::now();
240    let mut item = CheckItem::new(
241        "JA-04",
242        "Performance Regression Gate",
243        "Deployment blocked on performance regression",
244    )
245    .with_severity(Severity::Major)
246    .with_tps("Jidoka — quality gate");
247
248    // Check for benchmark configuration
249    let benches_dir = project_path.join("benches");
250    let has_benches = benches_dir.exists();
251
252    // Check for criterion in Cargo.toml
253    let cargo_toml = project_path.join("Cargo.toml");
254    let has_criterion = cargo_toml
255        .exists()
256        .then(|| std::fs::read_to_string(&cargo_toml).ok())
257        .flatten()
258        .map(|c| c.contains("criterion") || c.contains("divan") || c.contains("[bench]"))
259        .unwrap_or(false);
260
261    // Check CI for benchmarks
262    let has_bench_ci = check_ci_for_content(project_path, "bench");
263
264    // Check for hyperfine or similar
265    let makefile = project_path.join("Makefile");
266    let has_perf_make = makefile
267        .exists()
268        .then(|| std::fs::read_to_string(&makefile).ok())
269        .flatten()
270        .map(|c| c.contains("hyperfine") || c.contains("bench") || c.contains("perf"))
271        .unwrap_or(false);
272
273    item = item.with_evidence(Evidence {
274        evidence_type: EvidenceType::StaticAnalysis,
275        description: format!(
276            "Performance: benches_dir={}, criterion={}, ci_bench={}, make_perf={}",
277            has_benches, has_criterion, has_bench_ci, has_perf_make
278        ),
279        data: None,
280        files: Vec::new(),
281    });
282
283    item = apply_check_outcome(
284        item,
285        &[
286            (has_benches && has_criterion && has_bench_ci, CheckOutcome::Pass),
287            (
288                has_benches || has_criterion,
289                CheckOutcome::Partial("Benchmarks exist but not gated in CI"),
290            ),
291            (true, CheckOutcome::Partial("No performance regression detection")),
292        ],
293    );
294
295    item.finish_timed(start)
296}
297
298/// JA-05: Fairness Metric Circuit Breaker
299///
300/// **Claim:** Training stops on fairness regression.
301///
302/// **Rejection Criteria (Major):**
303/// - Protected class metric degrades >5% without alert
304pub fn check_fairness_metric_circuit_breaker(project_path: &Path) -> CheckItem {
305    let start = Instant::now();
306    let mut item = CheckItem::new(
307        "JA-05",
308        "Fairness Metric Circuit Breaker",
309        "Training stops on fairness regression",
310    )
311    .with_severity(Severity::Major)
312    .with_tps("Jidoka — ethical safeguard");
313
314    // Check for fairness-related code
315    let has_fairness_code = any_source_matches(project_path, "src/**/*.rs", |c| {
316        c.contains("fairness")
317            || c.contains("bias")
318            || c.contains("demographic_parity")
319            || c.contains("equalized_odds")
320            || c.contains("protected_class")
321    });
322
323    // Check for fairness testing
324    let has_fairness_tests = any_source_matches(project_path, "tests/**/*.rs", |c| {
325        c.contains("fairness") || c.contains("bias")
326    });
327
328    item = item.with_evidence(Evidence {
329        evidence_type: EvidenceType::StaticAnalysis,
330        description: format!("Fairness: code={}, tests={}", has_fairness_code, has_fairness_tests),
331        data: None,
332        files: Vec::new(),
333    });
334
335    // Check if project has ML that needs fairness monitoring
336    let has_ml = any_source_matches(project_path, "src/**/*.rs", |c| {
337        c.contains("classifier") || c.contains("predict") || c.contains("model")
338    });
339
340    item = apply_check_outcome(
341        item,
342        &[
343            (!has_ml || has_fairness_code || has_fairness_tests, CheckOutcome::Pass),
344            (true, CheckOutcome::Partial("ML without fairness monitoring")),
345        ],
346    );
347
348    item.finish_timed(start)
349}
350
351/// JA-06: Latency SLA Circuit Breaker
352///
353/// **Claim:** Deployment blocked on latency regression.
354///
355/// **Rejection Criteria (Major):**
356/// - P99 latency exceeds SLA in staging
357pub fn check_latency_sla_circuit_breaker(project_path: &Path) -> CheckItem {
358    let start = Instant::now();
359    let mut item = CheckItem::new(
360        "JA-06",
361        "Latency SLA Circuit Breaker",
362        "Deployment blocked on latency regression",
363    )
364    .with_severity(Severity::Major)
365    .with_tps("Jidoka — SLA enforcement");
366
367    // Check for latency-related code
368    let has_latency_monitoring = any_source_matches(project_path, "src/**/*.rs", |c| {
369        c.contains("latency")
370            || c.contains("p99")
371            || c.contains("p95")
372            || c.contains("percentile")
373            || c.contains("sla")
374    });
375
376    // Check for timing/duration tracking
377    let has_timing = any_source_matches(project_path, "src/**/*.rs", |c| {
378        c.contains("Instant::") || c.contains("Duration::") || c.contains("elapsed")
379    });
380
381    item = item.with_evidence(Evidence {
382        evidence_type: EvidenceType::StaticAnalysis,
383        description: format!(
384            "Latency: monitoring={}, timing={}",
385            has_latency_monitoring, has_timing
386        ),
387        data: None,
388        files: Vec::new(),
389    });
390
391    // Check if project has serving that needs latency SLA
392    let has_serving = any_source_matches(project_path, "src/**/*.rs", |c| {
393        c.contains("serve") || c.contains("inference") || c.contains("api")
394    });
395
396    item = apply_check_outcome(
397        item,
398        &[
399            (!has_serving || has_latency_monitoring, CheckOutcome::Pass),
400            (has_timing, CheckOutcome::Partial("Timing code exists but no SLA enforcement")),
401            (true, CheckOutcome::Partial("Serving without latency monitoring")),
402        ],
403    );
404
405    item.finish_timed(start)
406}
407
408/// JA-07: Memory Footprint Gate
409///
410/// **Claim:** Deployment blocked on excessive memory.
411///
412/// **Rejection Criteria (Major):**
413/// - Peak memory exceeds target by >20%
414pub fn check_memory_footprint_gate(project_path: &Path) -> CheckItem {
415    let start = Instant::now();
416    let mut item =
417        CheckItem::new("JA-07", "Memory Footprint Gate", "Deployment blocked on excessive memory")
418            .with_severity(Severity::Major)
419            .with_tps("Muda (Inventory) prevention");
420
421    // Check for memory profiling patterns
422    let has_memory_profiling = any_source_matches(project_path, "src/**/*.rs", |c| {
423        c.contains("memory")
424            || c.contains("heap")
425            || c.contains("allocator")
426            || c.contains("mem::size_of")
427    });
428
429    // Check for memory limits in CI or config
430    let has_memory_limits = check_ci_for_content(project_path, "memory")
431        || check_ci_for_content(project_path, "ulimit");
432
433    // Check Makefile for memory profiling
434    let makefile = project_path.join("Makefile");
435    let has_heaptrack = makefile
436        .exists()
437        .then(|| std::fs::read_to_string(&makefile).ok())
438        .flatten()
439        .map(|c| c.contains("heaptrack") || c.contains("valgrind") || c.contains("massif"))
440        .unwrap_or(false);
441
442    item = item.with_evidence(Evidence {
443        evidence_type: EvidenceType::StaticAnalysis,
444        description: format!(
445            "Memory: profiling={}, limits={}, heaptrack={}",
446            has_memory_profiling, has_memory_limits, has_heaptrack
447        ),
448        data: None,
449        files: Vec::new(),
450    });
451
452    item = apply_check_outcome(
453        item,
454        &[
455            (has_memory_profiling && (has_memory_limits || has_heaptrack), CheckOutcome::Pass),
456            (
457                has_memory_profiling || has_heaptrack,
458                CheckOutcome::Partial("Memory profiling available but not gated"),
459            ),
460            (true, CheckOutcome::Partial("No memory footprint gate")),
461        ],
462    );
463
464    item.finish_timed(start)
465}
466
467/// JA-08: Security Scan Gate
468///
469/// **Claim:** Build blocked on security findings.
470///
471/// **Rejection Criteria (Critical):**
472/// - High/Critical vulnerability in build
473pub fn check_security_scan_gate(project_path: &Path) -> CheckItem {
474    let start = Instant::now();
475    let mut item =
476        CheckItem::new("JA-08", "Security Scan Gate", "Build blocked on security findings")
477            .with_severity(Severity::Critical)
478            .with_tps("Jidoka — security gate");
479
480    // Check for security scanning tools
481    let has_audit_ci = check_ci_for_content(project_path, "cargo audit");
482    let has_deny_ci = check_ci_for_content(project_path, "cargo deny");
483
484    // Check for deny.toml
485    let deny_toml = project_path.join("deny.toml");
486    let has_deny_config = deny_toml.exists();
487
488    // Check for security workflow
489    let security_workflow = project_path.join(".github/workflows/security.yml");
490    let has_security_workflow = security_workflow.exists();
491
492    item = item.with_evidence(Evidence {
493        evidence_type: EvidenceType::StaticAnalysis,
494        description: format!(
495            "Security: audit_ci={}, deny_ci={}, deny_config={}, security_workflow={}",
496            has_audit_ci, has_deny_ci, has_deny_config, has_security_workflow
497        ),
498        data: None,
499        files: Vec::new(),
500    });
501
502    item = apply_check_outcome(
503        item,
504        &[
505            (has_deny_config && (has_audit_ci || has_deny_ci), CheckOutcome::Pass),
506            (
507                has_audit_ci || has_deny_ci || has_deny_config,
508                CheckOutcome::Partial("Security scanning partially configured"),
509            ),
510            (true, CheckOutcome::Fail("No security scanning in CI")),
511        ],
512    );
513
514    item.finish_timed(start)
515}
516
517/// JA-09: License Compliance Gate
518///
519/// **Claim:** Build blocked on license violation.
520///
521/// **Rejection Criteria (Major):**
522/// - Disallowed license in dependency tree
523pub fn check_license_compliance_gate(project_path: &Path) -> CheckItem {
524    let start = Instant::now();
525    let mut item =
526        CheckItem::new("JA-09", "License Compliance Gate", "Build blocked on license violation")
527            .with_severity(Severity::Major)
528            .with_tps("Legal controls pillar");
529
530    // Check for deny.toml with licenses section
531    let deny_toml = project_path.join("deny.toml");
532    let has_license_config = deny_toml
533        .exists()
534        .then(|| std::fs::read_to_string(&deny_toml).ok())
535        .flatten()
536        .map(|c| c.contains("[licenses]"))
537        .unwrap_or(false);
538
539    // Check for cargo-deny in CI
540    let has_deny_licenses_ci = check_ci_for_content(project_path, "cargo deny check licenses");
541
542    // Check for LICENSE file
543    let has_license_file = project_path.join("LICENSE").exists()
544        || project_path.join("LICENSE.md").exists()
545        || project_path.join("LICENSE.txt").exists()
546        || project_path.join("LICENSE-MIT").exists()
547        || project_path.join("LICENSE-APACHE").exists();
548
549    item = item.with_evidence(Evidence {
550        evidence_type: EvidenceType::StaticAnalysis,
551        description: format!(
552            "License: config={}, ci_check={}, license_file={}",
553            has_license_config, has_deny_licenses_ci, has_license_file
554        ),
555        data: None,
556        files: Vec::new(),
557    });
558
559    item = apply_check_outcome(
560        item,
561        &[
562            (has_license_config && has_deny_licenses_ci, CheckOutcome::Pass),
563            (
564                has_license_file && (has_license_config || has_deny_licenses_ci),
565                CheckOutcome::Partial("License file exists, partial enforcement"),
566            ),
567            (has_license_file, CheckOutcome::Partial("License file exists but no automated check")),
568            (true, CheckOutcome::Fail("No license compliance setup")),
569        ],
570    );
571
572    item.finish_timed(start)
573}
574
575/// JA-10: Documentation Gate
576///
577/// **Claim:** PR blocked without documentation updates.
578///
579/// **Rejection Criteria (Minor):**
580/// - Public API change without doc update
581pub fn check_documentation_gate(project_path: &Path) -> CheckItem {
582    let start = Instant::now();
583    let mut item =
584        CheckItem::new("JA-10", "Documentation Gate", "PR blocked without documentation updates")
585            .with_severity(Severity::Minor)
586            .with_tps("Knowledge transfer");
587
588    // Check for doc tests
589    let has_doc_tests = check_ci_for_content(project_path, "cargo doc");
590
591    // Check for deny warnings on missing docs
592    let lib_rs = project_path.join("src/lib.rs");
593    let has_deny_missing_docs = lib_rs
594        .exists()
595        .then(|| std::fs::read_to_string(&lib_rs).ok())
596        .flatten()
597        .map(|c| c.contains("#![deny(missing_docs)]") || c.contains("#![warn(missing_docs)]"))
598        .unwrap_or(false);
599
600    // Check for README
601    let has_readme = project_path.join("README.md").exists();
602
603    // Check for docs directory or book
604    let has_docs_dir = project_path.join("docs").exists() || project_path.join("book").exists();
605
606    item = item.with_evidence(Evidence {
607        evidence_type: EvidenceType::StaticAnalysis,
608        description: format!(
609            "Docs: ci_doc={}, deny_missing={}, readme={}, docs_dir={}",
610            has_doc_tests, has_deny_missing_docs, has_readme, has_docs_dir
611        ),
612        data: None,
613        files: Vec::new(),
614    });
615
616    item = apply_check_outcome(
617        item,
618        &[
619            (
620                (has_doc_tests && has_deny_missing_docs) || (has_readme && has_docs_dir),
621                CheckOutcome::Pass,
622            ),
623            (has_readme, CheckOutcome::Partial("README exists but no documentation enforcement")),
624            (true, CheckOutcome::Fail("No documentation gate")),
625        ],
626    );
627
628    item.finish_timed(start)
629}
630
631/// Helper: Check if content exists in any CI configuration
632fn check_ci_for_content(project_path: &Path, content: &str) -> bool {
633    let ci_configs = [
634        project_path.join(".github/workflows/ci.yml"),
635        project_path.join(".github/workflows/test.yml"),
636        project_path.join(".github/workflows/rust.yml"),
637        project_path.join(".github/workflows/security.yml"),
638        project_path.join(".github/workflows/release.yml"),
639    ];
640
641    for ci_path in &ci_configs {
642        if ci_path.exists() {
643            if let Ok(file_content) = std::fs::read_to_string(ci_path) {
644                if file_content.contains(content) {
645                    return true;
646                }
647            }
648        }
649    }
650    false
651}
652
653#[cfg(test)]
654#[path = "jidoka_tests.rs"]
655mod tests;