// m7_9_acceptance.rs --- T M7.9 acceptance tests. // //! Acceptance suite for T M7.9 ("Audit lint configuration"). The //! five tests below correspond, in order, to the five bullets in //! `spec/pmacs-tasks.tex` ยง "T M7.9 -- Audit lint configuration". //! //! Bullet 1: detects all seven rule classes //! Bullet 2: emits JSON with file/line/rule/snippet //! Bullet 3: sample CI workflow YAML present for three forges //! Bullet 4: lint over the (pre-M7.11) REPL package's in-tree source //! Bullet 5: 1000-line package lints in under one second use std::collections::HashSet; use std::path::Path; use std::time::Instant; use pmacs::audit::{AuditEngine, AuditReport, Severity}; // --------------------------------------------------------------------------- // Bullet 1: seven rule classes detected on synthetic snippets // --------------------------------------------------------------------------- // // The spec lists seven *rule classes*; the implementation expands them into // fifteen tree-sitter patterns (e.g., the "no-FFI escape hatches" class // covers four distinct patterns: ffi.cdef/load/metatype, package.loadlib, // package.cpath mutation; consolidating them under a single rule name would // make findings less actionable). The acceptance check confirms every // *class* fires at least once; the per-pattern test suite in // `src/audit/mod.rs::tests` verifies the discrimination within a class. #[test] fn seven_rule_classes_each_fire_on_synthetic_snippets() { let engine = AuditEngine::new().expect("rule set compiles"); let src = r#" -- Class 1: no private-surface access local a = require("pmacs._internal.foo") local b = require("pmacs.core.bar") local _pmacs_internal_x = 1 local _core_y = 2 -- Class 2: no FFI escape hatches ffi.cdef[[ int x; ]] ffi.load("c") ffi.metatype(t, mt) package.loadlib("/lib/x.so", "init") package.cpath = "/extra/?.so" -- Class 3: no debug-library cancellation interference debug.sethook(myhook, "c") debug.setmetatable(other, mt) -- Class 4: no environment escape rawget(_G, "secret") rawset(_G, "ok", 1) setfenv(2, env) getfenv(0) -- Class 5: reach-around to other packages local x = require("otherpkg.private") -- Class 6: filesystem mutation (warn) io.open("/tmp/x", "w") os.remove("/tmp/y") os.rename("a", "b") -- Class 7: process spawning (warn) io.popen("ls") os.execute("rm -rf /") pmacs.process.spawn("ls") "#; let findings = engine.audit_source("synthetic.lua", src); let rules: HashSet<&str> = findings.iter().map(|f| f.rule.as_str()).collect(); let class_to_rule_alternatives: &[(&str, &[&str])] = &[ ( "private-surface", &[ "no-private-surface-require", "no-private-surface-identifier", ], ), ( "ffi-and-loadlib", &[ "no-ffi-call", "no-package-loadlib", "no-package-cpath-mutation", ], ), ( "debug-cancellation", &["no-debug-sethook", "no-debug-setmetatable"], ), ( "env-escape", &["no-rawget-rawset-on-globals", "no-setfenv-getfenv"], ), ("reach-around", &["reach-around-require"]), ( "fs-mutation", &["no-fs-mutation-io-open-write", "no-fs-mutation-os"], ), ( "process-spawn", &[ "no-process-spawn-io", "no-process-spawn-os", "no-process-spawn-pmacs", ], ), ]; for (class, alts) in class_to_rule_alternatives { let any = alts.iter().any(|r| rules.contains(*r)); assert!( any, "rule class `{class}` did not fire; alternatives {alts:?} not in {rules:?}" ); } // And each pattern fires at least once (distinct rules >= 13/15; // private-surface-{require,identifier}, both ffi-call patterns, // both fs-mutation patterns, both env-escape patterns, all three // spawn patterns, etc., are all exercised above). Two patterns // that cover sub-cases of a class are tested in the unit suite. assert!( rules.len() >= 13, "expected ~13+ distinct rule names, got {}: {rules:?}", rules.len() ); } // --------------------------------------------------------------------------- // Bullet 2: JSON report shape // --------------------------------------------------------------------------- #[test] fn json_report_has_file_line_rule_severity_message_snippet() { let engine = AuditEngine::new().unwrap(); let findings = engine.audit_source("demo.lua", "ffi.load(\"c\")\nio.popen(\"ls\")\n"); let report = AuditReport::new(findings); let json = serde_json::to_value(&report).expect("serialize"); let arr = json["findings"].as_array().expect("findings is an array"); assert!(!arr.is_empty(), "report has at least one finding"); let first = &arr[0]; for key in [ "file", "line", "column", "rule", "severity", "message", "snippet", ] { assert!( first.get(key).is_some(), "finding missing field `{key}` in {first:?}" ); } assert!(json["summary"]["errors"].is_number()); assert!(json["summary"]["warnings"].is_number()); assert!(json["summary"]["infos"].is_number()); // Severity values are stable lowercase. let sev = first["severity"].as_str().expect("severity is a string"); assert!( matches!(sev, "error" | "warning" | "info"), "unknown severity `{sev}`" ); } #[test] fn json_round_trips_through_serde() { let engine = AuditEngine::new().unwrap(); let findings = engine.audit_source("demo.lua", "ffi.load(\"c\")\n"); let report = AuditReport::new(findings); let s = serde_json::to_string(&report).unwrap(); let back: AuditReport = serde_json::from_str(&s).unwrap(); assert_eq!(back.summary.errors, report.summary.errors); assert_eq!(back.findings.len(), report.findings.len()); assert_eq!(back.findings[0].rule, report.findings[0].rule); } // --------------------------------------------------------------------------- // Bullet 3: sample CI workflows present for three forges // --------------------------------------------------------------------------- #[test] fn sample_ci_workflows_exist_for_three_forges() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let forges = [ "audit/ci/github-actions.yml", "audit/ci/gitlab-ci.yml", "audit/ci/forgejo-actions.yml", ]; for f in forges { let p = root.join(f); let body = std::fs::read_to_string(&p) .unwrap_or_else(|e| panic!("expected sample CI workflow at {p:?}: {e}")); assert!( body.contains("pmacs-audit") || body.contains("pmacs_audit"), "{f} should reference the pmacs-audit binary; got:\n{body}" ); // Sanity-check the file looks like YAML (not a Markdown stub). assert!( body.contains(':'), "{f} should look like YAML (contain key:value pairs)" ); } } // --------------------------------------------------------------------------- // Bullet 4: lint runs against the in-tree REPL source // --------------------------------------------------------------------------- // // As of T M7.11 the REPL is a manifest-bearing package at // `builtin/packages/repl/`. The acceptance contract: any findings // against the package's source must be either zero or // *classifiable* -- that is, instances of rules whose Warning/Info // severity reflects expected REPL behavior (process spawning, // primarily). A finding under any Error-severity rule is a hard // failure here. // // `tests/m7_11_acceptance.rs` repeats this assertion as part of the // M7.11 migration's own acceptance bullet 4; the duplication is // intentional, not a copy-paste oversight: each milestone owns its // own audit gate, and the M7.9 suite also keeps a check so a // future migration that renames or moves the REPL doesn't silently // drop audit coverage. #[test] fn lint_against_in_tree_repl_source_has_no_error_severity_findings() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let repl_dir = root.join("builtin/packages/repl"); assert!( repl_dir.is_dir(), "expected migrated REPL package dir at {repl_dir:?} (T M7.11)" ); let engine = AuditEngine::new().unwrap(); let findings = engine .audit_dir(&repl_dir) .expect("read+audit migrated REPL package"); let errors: Vec<_> = findings .iter() .filter(|f| f.severity == Severity::Error) .collect(); assert!( errors.is_empty(), "in-tree REPL has Error-severity findings; either fix the REPL or reclassify the rule.\n\ findings: {errors:#?}" ); // The REPL is permitted to spawn processes (it *is* the // process-spawning subsystem). Document the expected // classification: every finding is Warn-level // `no-process-spawn-pmacs` against `pmacs.process.spawn`. for f in &findings { assert_eq!( f.severity, Severity::Warning, "unexpected non-warning finding in REPL: {f:?}" ); assert_eq!( f.rule, "no-process-spawn-pmacs", "unexpected rule in REPL findings (expected only the spawn warn): {f:?}" ); } } // --------------------------------------------------------------------------- // Bullet 5: 1000-line perf budget // --------------------------------------------------------------------------- #[test] fn lint_completes_in_under_one_second_on_thousand_line_package() { // Build a 1000-line synthetic source. Mix of patterns so the // worst-case (all 15 rules engaged) is exercised, not a tight // loop of identical lines that the parser would chew through // unrealistically fast. let mut src = String::new(); let pattern = [ "local a = require(\"foo\")", "ffi.cdef[[ int x; ]]", "io.popen(\"ls\")", "os.execute(\"echo hi\")", "local b = require(\"otherpkg.x\")", "io.open(\"/tmp/x\", \"w\")", "os.remove(\"/tmp/y\")", "debug.sethook(h, \"c\")", "rawget(_G, \"k\")", "package.cpath = \"/extra/?.so\"", ]; for i in 0..1000 { src.push_str(pattern[i % pattern.len()]); src.push('\n'); } assert_eq!(src.lines().count(), 1000); let engine = AuditEngine::new().unwrap(); let t = Instant::now(); let findings = engine.audit_source("perf.lua", &src); let elapsed = t.elapsed(); assert!( elapsed.as_secs_f64() < 1.0, "lint of 1000-line package took {elapsed:?}; spec budget is < 1s" ); assert!( !findings.is_empty(), "synthetic 1000-line file should produce findings (engine alive check)" ); // For empirical record: print to stderr so CI logs capture it. eprintln!( "[T M7.9] 1000-line synthetic perf: {:?} for {} findings", elapsed, findings.len() ); }