Skip to main content

batuta/falsification/
invariants.rs

1//! Architectural Invariants - CRITICAL Checks
2//!
3//! Section 10 of the Popperian Falsification Checklist.
4//! Any failure in this section = Project FAIL.
5//!
6//! - AI-01: Declarative YAML Configuration
7//! - AI-02: Zero Scripting in Production
8//! - AI-03: Pure Rust Testing (No Jest/Pytest)
9//! - AI-04: WASM-First Browser Support
10//! - AI-05: Declarative Schema Validation
11
12use crate::falsification::helpers::{apply_check_outcome, CheckOutcome};
13use crate::falsification::types::*;
14use std::path::Path;
15use std::time::Instant;
16
17/// Evaluate all architectural invariants.
18pub fn evaluate_all(project_path: &Path) -> Vec<CheckItem> {
19    vec![
20        check_declarative_yaml(project_path),
21        check_zero_scripting(project_path),
22        check_pure_rust_testing(project_path),
23        check_wasm_first(project_path),
24        check_schema_validation(project_path),
25    ]
26}
27
28/// AI-01: Declarative YAML Configuration
29///
30/// **Claim:** Project offers full functionality via declarative YAML without code.
31///
32/// **Rejection Criteria (CRITICAL):**
33/// - Any core feature unavailable via YAML config
34/// - User must write Rust/code to use basic functionality
35pub fn check_declarative_yaml(project_path: &Path) -> CheckItem {
36    let start = Instant::now();
37    let mut item = CheckItem::new(
38        "AI-01",
39        "Declarative YAML Configuration",
40        "Project offers full functionality via declarative YAML without code",
41    )
42    .with_severity(Severity::Critical)
43    .with_tps("Poka-Yoke — enable non-developers");
44
45    // Check for YAML config files
46    let yaml_patterns = ["*.yaml", "*.yml"];
47    let mut yaml_files = Vec::new();
48
49    for pattern in yaml_patterns {
50        if let Ok(entries) = glob::glob(&format!("{}/{}", project_path.display(), pattern)) {
51            for entry in entries.flatten() {
52                yaml_files.push(entry);
53            }
54        }
55        // Also check config directories (both singular and plural)
56        for dir in ["config", "configs"] {
57            if let Ok(entries) =
58                glob::glob(&format!("{}/{}/**/{}", project_path.display(), dir, pattern))
59            {
60                for entry in entries.flatten() {
61                    yaml_files.push(entry);
62                }
63            }
64        }
65    }
66
67    // Check for schema definitions (serde structs)
68    let has_config_module = project_path.join("src/config.rs").exists()
69        || project_path.join("src/config/mod.rs").exists();
70
71    // Check for example configs
72    let has_examples = project_path.join("examples").exists() || !yaml_files.is_empty();
73
74    item = item.with_evidence(Evidence::file_audit(
75        format!("Found {} YAML config files", yaml_files.len()),
76        yaml_files.clone(),
77    ));
78
79    item = apply_check_outcome(
80        item,
81        &[
82            (has_config_module && has_examples, CheckOutcome::Pass),
83            (
84                has_config_module || !yaml_files.is_empty(),
85                CheckOutcome::Partial("Config module exists but examples incomplete"),
86            ),
87            (true, CheckOutcome::Fail("No declarative YAML configuration found")),
88        ],
89    );
90
91    item.finish_timed(start)
92}
93
94/// AI-02: Zero Scripting in Production
95///
96/// **Claim:** No Python/JavaScript/Lua in production runtime paths.
97///
98/// **Rejection Criteria (CRITICAL):**
99/// - Any `.py`, `.js`, `.lua` in src/ or runtime
100/// - pyo3, napi-rs, mlua in non-dev dependencies
101/// - Interpreter embedded in binary
102pub fn check_zero_scripting(project_path: &Path) -> CheckItem {
103    let start = Instant::now();
104    let mut item = CheckItem::new(
105        "AI-02",
106        "Zero Scripting in Production",
107        "No Python/JavaScript/Lua in production runtime paths",
108    )
109    .with_severity(Severity::Critical)
110    .with_tps("Jidoka — type safety, determinism");
111
112    // Check for scripting language files in src/
113    let violations = find_glob_violations(
114        project_path,
115        &["src/**/*.py", "src/**/*.js", "src/**/*.ts", "src/**/*.lua", "src/**/*.rb"],
116        &["/target/", "/pkg/", ".wasm"],
117    );
118
119    // Check Cargo.toml for scripting runtime dependencies
120    let scripting_deps = super::helpers::find_scripting_deps(project_path);
121
122    item = item.with_evidence(Evidence::dependency_audit(
123        "Checked Cargo.toml for scripting runtimes".to_string(),
124        format!("Found: {:?}", scripting_deps),
125    ));
126
127    item = item.with_evidence(Evidence::file_audit(
128        format!("Found {} scripting files in src/", violations.len()),
129        violations.clone(),
130    ));
131
132    let fail_reasons = {
133        let mut r = Vec::new();
134        if !violations.is_empty() {
135            r.push(format!("{} scripting files in src/", violations.len()));
136        }
137        if !scripting_deps.is_empty() {
138            r.push(format!("Scripting deps: {:?}", scripting_deps));
139        }
140        r.join("; ")
141    };
142    item = apply_check_outcome(
143        item,
144        &[
145            (violations.is_empty() && scripting_deps.is_empty(), CheckOutcome::Pass),
146            (true, CheckOutcome::Fail(&fail_reasons)),
147        ],
148    );
149
150    item.finish_timed(start)
151}
152
153/// Find files matching glob patterns, excluding paths containing any exclude string
154fn find_glob_violations(
155    project_path: &Path,
156    patterns: &[&str],
157    excludes: &[&str],
158) -> Vec<std::path::PathBuf> {
159    let mut results = Vec::new();
160    for pattern in patterns {
161        let Ok(entries) = glob::glob(&format!("{}/{}", project_path.display(), pattern)) else {
162            continue;
163        };
164        for entry in entries.flatten() {
165            let path_str = entry.to_string_lossy();
166            if !excludes.iter().any(|ex| path_str.contains(ex)) {
167                results.push(entry);
168            }
169        }
170    }
171    results
172}
173
174/// Check if Rust tests exist in the project
175fn has_rust_tests(project_path: &Path) -> bool {
176    project_path.join("tests").exists()
177        || glob::glob(&format!("{}/src/**/*.rs", project_path.display()))
178            .ok()
179            .map(|entries| {
180                entries.flatten().any(|p| {
181                    std::fs::read_to_string(&p)
182                        .ok()
183                        .is_some_and(|c| c.contains("#[test]") || c.contains("#[cfg(test)]"))
184                })
185            })
186            .unwrap_or(false)
187}
188
189/// AI-03: Pure Rust Testing (No Jest/Pytest)
190///
191/// **Claim:** All tests written in Rust, no external test frameworks.
192///
193/// **Rejection Criteria (CRITICAL):**
194/// - Any Jest, Mocha, Pytest, unittest files
195/// - package.json with test scripts
196/// - requirements-dev.txt with pytest
197pub fn check_pure_rust_testing(project_path: &Path) -> CheckItem {
198    let start = Instant::now();
199    let mut item = CheckItem::new(
200        "AI-03",
201        "Pure Rust Testing",
202        "All tests written in Rust, no external test frameworks",
203    )
204    .with_severity(Severity::Critical)
205    .with_tps("Zero scripting policy");
206
207    let mut violations = find_glob_violations(
208        project_path,
209        &[
210            "**/*.test.js",
211            "**/*.spec.js",
212            "**/*.test.ts",
213            "**/*.spec.ts",
214            "**/jest.config.*",
215            "**/vitest.config.*",
216        ],
217        &["node_modules"],
218    );
219
220    violations.extend(find_glob_violations(
221        project_path,
222        &["**/test_*.py", "**/*_test.py", "**/conftest.py", "**/pytest.ini", "**/pyproject.toml"],
223        &["venv", ".venv"],
224    ));
225
226    let package_json = project_path.join("package.json");
227    if let Ok(content) = std::fs::read_to_string(&package_json) {
228        if content.contains("\"test\"") || content.contains("\"jest\"") {
229            violations.push(package_json);
230        }
231    }
232
233    let node_modules = project_path.join("node_modules");
234    if node_modules.exists() {
235        violations.push(node_modules);
236    }
237
238    violations.extend(find_glob_violations(project_path, &["**/__pycache__"], &[]));
239
240    item = item.with_evidence(Evidence::file_audit(
241        format!("Found {} non-Rust test artifacts", violations.len()),
242        violations.clone(),
243    ));
244
245    let has_tests = has_rust_tests(project_path);
246    let fail_msg = format!(
247        "Found {} non-Rust test artifacts: {:?}",
248        violations.len(),
249        violations.iter().take(5).collect::<Vec<_>>()
250    );
251    item = apply_check_outcome(
252        item,
253        &[
254            (violations.is_empty() && has_tests, CheckOutcome::Pass),
255            (
256                violations.is_empty(),
257                CheckOutcome::Partial("No violations but no Rust tests detected"),
258            ),
259            (true, CheckOutcome::Fail(&fail_msg)),
260        ],
261    );
262
263    item.finish_timed(start)
264}
265
266/// Check if a JS file path should be excluded from analysis
267fn is_excluded_js_path(path_str: &str) -> bool {
268    const EXCLUDED_DIRS: &[&str] =
269        &["node_modules", "/pkg/", "/dist/", "/target/", "/book/", "/book-output/", "/docs/"];
270    const EXCLUDED_PREFIXES: &[&str] =
271        &["target/", "pkg/", "dist/", "book/", "book-output/", "docs/"];
272
273    EXCLUDED_DIRS.iter().any(|d| path_str.contains(d))
274        || EXCLUDED_PREFIXES.iter().any(|p| path_str.starts_with(p))
275}
276
277/// Find non-excluded JS files in project
278fn find_js_files(project_path: &Path) -> Vec<std::path::PathBuf> {
279    let Ok(entries) = glob::glob(&format!("{}/**/*.js", project_path.display())) else {
280        return Vec::new();
281    };
282    entries.flatten().filter(|entry| !is_excluded_js_path(&entry.to_string_lossy())).collect()
283}
284
285/// Detect JS framework in package.json
286fn detect_js_framework(project_path: &Path) -> bool {
287    let package_json = project_path.join("package.json");
288    let Ok(content) = std::fs::read_to_string(package_json) else {
289        return false;
290    };
291    ["react", "vue", "svelte", "angular", "next", "nuxt"].iter().any(|fw| content.contains(fw))
292}
293
294/// Parse WASM feature/bindgen info from Cargo.toml
295fn parse_wasm_cargo_info(project_path: &Path) -> (bool, bool) {
296    let cargo_toml = project_path.join("Cargo.toml");
297    let Ok(content) = std::fs::read_to_string(cargo_toml) else {
298        return (false, false);
299    };
300    let has_feature = content.contains("wasm") || content.contains("web");
301    let has_bindgen = content.contains("wasm-bindgen")
302        || content.contains("wasm-pack")
303        || content.contains("web-sys");
304    (has_feature, has_bindgen)
305}
306
307/// AI-04: WASM-First Browser Support
308///
309/// **Claim:** Browser functionality via WASM, not JavaScript.
310///
311/// **Rejection Criteria (CRITICAL):**
312/// - JS files beyond minimal WASM glue
313/// - npm dependencies for core functionality
314/// - React/Vue/Svelte instead of WASM UI
315pub fn check_wasm_first(project_path: &Path) -> CheckItem {
316    let start = Instant::now();
317    let mut item = CheckItem::new(
318        "AI-04",
319        "WASM-First Browser Support",
320        "Browser functionality via WASM, not JavaScript",
321    )
322    .with_severity(Severity::Critical)
323    .with_tps("Zero scripting, sovereignty");
324
325    let (has_wasm_feature, has_wasm_bindgen) = parse_wasm_cargo_info(project_path);
326    let has_wasm_module =
327        project_path.join("src/wasm.rs").exists() || project_path.join("src/lib.rs").exists();
328    let js_files = find_js_files(project_path);
329    let has_js_framework = detect_js_framework(project_path);
330
331    item = item.with_evidence(Evidence::file_audit(
332        format!(
333            "WASM: feature={}, bindgen={}, JS files={}",
334            has_wasm_feature,
335            has_wasm_bindgen,
336            js_files.len()
337        ),
338        js_files.clone(),
339    ));
340
341    let too_many_js_msg = format!("Too many JS files ({}) beyond WASM glue", js_files.len());
342    let wasm_partial_msg = format!("WASM support exists but {} JS files found", js_files.len());
343    let has_wasm_support = has_wasm_bindgen || has_wasm_feature;
344    item = apply_check_outcome(
345        item,
346        &[
347            (
348                has_js_framework,
349                CheckOutcome::Fail("JavaScript framework detected (React/Vue/Svelte)"),
350            ),
351            (js_files.len() > 5, CheckOutcome::Fail(&too_many_js_msg)),
352            (has_wasm_support && js_files.is_empty(), CheckOutcome::Pass),
353            (has_wasm_support, CheckOutcome::Partial(&wasm_partial_msg)),
354            (
355                has_wasm_module && js_files.is_empty(),
356                CheckOutcome::Partial("No explicit WASM feature but no JS violations"),
357            ),
358            (true, CheckOutcome::Fail("No WASM support detected")),
359        ],
360    );
361
362    item.finish_timed(start)
363}
364
365/// AI-05: Declarative Schema Validation
366///
367/// **Claim:** YAML configs validated against typed schema.
368///
369/// **Rejection Criteria (CRITICAL):**
370/// - Invalid YAML silently accepted
371/// - No JSON Schema or serde validation
372/// - Runtime panics on bad config
373pub fn check_schema_validation(project_path: &Path) -> CheckItem {
374    let start = Instant::now();
375    let mut item = CheckItem::new(
376        "AI-05",
377        "Declarative Schema Validation",
378        "YAML configs validated against typed schema",
379    )
380    .with_severity(Severity::Critical)
381    .with_tps("Poka-Yoke — prevent config errors");
382
383    // Check for serde in Cargo.toml
384    let schema = super::helpers::detect_schema_deps(project_path);
385    let has_serde = schema.has_serde;
386    let has_serde_yaml = schema.has_serde_yaml;
387    let has_validator = schema.has_validator;
388
389    // Check for config struct with Deserialize
390    let has_config_struct = super::helpers::has_deserialize_config_struct(project_path);
391
392    // Check for JSON Schema files
393    let has_json_schema = glob::glob(&format!("{}/**/*.schema.json", project_path.display()))
394        .ok()
395        .map(|mut entries| entries.next().is_some())
396        .unwrap_or(false);
397
398    item = item.with_evidence(Evidence::schema_validation(
399        format!(
400            "serde={}, yaml={}, validator={}, config_struct={}, json_schema={}",
401            has_serde, has_serde_yaml, has_validator, has_config_struct, has_json_schema
402        ),
403        format!(
404            "Schema validation: {}",
405            if has_config_struct || has_json_schema { "PRESENT" } else { "MISSING" }
406        ),
407    ));
408
409    let has_full_serde = has_serde && has_serde_yaml && has_config_struct;
410    item = apply_check_outcome(
411        item,
412        &[
413            (has_full_serde && (has_validator || has_json_schema), CheckOutcome::Pass),
414            (
415                has_full_serde,
416                CheckOutcome::Partial("Basic serde validation but no explicit validator"),
417            ),
418            (
419                has_serde && has_config_struct,
420                CheckOutcome::Partial("Config struct exists but YAML support unclear"),
421            ),
422            (true, CheckOutcome::Fail("No typed schema validation for configs")),
423        ],
424    );
425
426    item.finish_timed(start)
427}
428
429#[cfg(test)]
430#[path = "invariants_tests.rs"]
431mod tests;