use active_core::lint::{lint, lint_with, LintOptions, ALL_RULES};
use active_core::{parse, Engine};
use std::collections::HashSet;
use std::path::Path;
fn parse_ok(src: &str) -> active_core::Module {
parse(src, "lint").unwrap_or_else(|d| panic!("parse failed: {d:?}"))
}
fn lint_codes(src: &str) -> Vec<String> {
lint(&parse_ok(src))
.iter()
.map(|d| d.code.clone())
.collect()
}
fn lint_messages(src: &str) -> Vec<String> {
lint(&parse_ok(src))
.iter()
.map(|d| d.message.clone())
.collect()
}
#[test]
fn rule_ids_are_stable_and_complete() {
assert_eq!(
ALL_RULES,
&[
"unused-lets",
"depends-on",
"complexity",
"hardcoded-secrets",
"deprecated"
]
);
}
#[test]
fn unused_lets_fire_only_for_unreferenced_bindings() {
let diags = lint(&parse_ok("let used = 1\nlet idle = 2\nport = used\n"));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "L001");
assert!(diags[0].message.contains("idle"), "{:?}", diags[0]);
assert_eq!(diags[0].severity, active_core::Severity::Warning);
assert!(diags[0].span.is_some(), "bindings carry spans");
}
#[test]
fn uses_through_patches_constraints_and_schemas_count() {
let src = "let limit = 1\nlet cap = 5\nlet n = 3\n\
# @constraint: value <= n\nport = 1\n\
schema = { port = Int(max = cap) }\n\
@patch(\"port\", limit)\n";
assert!(
lint(&parse_ok(src)).iter().all(|d| d.code != "L001"),
"{src}"
);
}
#[test]
fn depends_on_completeness_dangling_and_self_scope() {
let diags = lint(&parse_ok(
"let max = 4\n[server]\ndepends_on = [\"max\"]\nport = max\n",
));
assert!(diags.iter().all(|d| d.code != "L002"), "{diags:?}");
let diags = lint(&parse_ok(
"[server]\ndepends_on = []\nport = max\nlet max = 4\n",
));
assert!(
diags
.iter()
.any(|d| d.code == "L002" && d.message.contains("missing")),
"{diags:?}"
);
let diags = lint(&parse_ok(
"[server]\ndepends_on = [\"nowhere\"]\nport = 1\n",
));
assert!(
diags
.iter()
.any(|d| d.code == "L002" && d.message.contains("dangling")),
"{diags:?}"
);
let diags = lint(&parse_ok(
"[server]\ndepends_on = [\"server.port\"]\nport = 1\n",
));
assert!(
diags
.iter()
.any(|d| d.code == "L002" && d.message.contains("unnecessary")),
"{diags:?}"
);
}
#[test]
fn depends_on_never_exports_and_cycles_fail() {
let module = parse_ok("[server]\ndepends_on = [\"db\"]\nport = 1\n[db]\nhost = \"h\"\n");
let (value, _) = Engine::new().evaluate(&module).expect("must evaluate");
let json = active_core::convert::value_to_pretty_json(&value);
assert!(
!json.contains("depends_on"),
"metadata must not export: {json}"
);
let module = parse_ok("[a]\ndepends_on = [\"b\"]\nx = 1\n[b]\ndepends_on = [\"a\"]\ny = 2\n");
let err = Engine::new()
.evaluate(&module)
.expect_err("cycle must fail");
assert_eq!(err.code(), "S001");
}
#[test]
fn depends_on_malformed_forms_are_e022() {
for src in [
"[server]\ndepends_on = [1, 2]\nport = 1\n",
"servers = [{ depends_on = [\"a\"] }]\n",
] {
let diags = parse(src, "lint").expect_err("must fail");
assert!(diags.iter().any(|d| d.code == "E022"), "{diags:?}");
}
let module = parse_ok("profile \"p\" {\n depends_on = \"x\"\n}\n");
assert!(module.depends_on.is_empty());
let diags =
parse("profile \"p\" {\n depends_on = [\"a\"]\n}\n", "lint").expect_err("must fail");
assert!(diags.iter().any(|d| d.code == "E022"), "{diags:?}");
}
#[test]
fn complexity_counts_operators_not_data() {
let mut terms = String::from("x = 1");
for _ in 0..149 {
terms.push_str(" + 1");
}
terms.push('\n');
let diags = lint(&parse_ok(&terms));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "L003");
assert!(diags[0].message.contains("149"), "{:?}", diags[0]);
let mut arr = String::from("xs = [");
for i in 0..200 {
arr.push_str(&format!("{i},"));
}
arr.push_str("]\n");
assert!(lint(&parse_ok(&arr)).is_empty());
}
#[test]
fn hardcoded_secrets_name_and_entropy_signals() {
let diags = lint(&parse_ok("[db]\npassword = \"s3cr3t-hunter2\"\n"));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "L004");
assert!(
!diags[0].message.contains("s3cr3t"),
"value must not leak: {:?}",
diags[0]
);
for value in ["changeme", "test", "***SECRET***", ""] {
let src = format!("[db]\npassword = \"{value}\"\n");
assert!(lint(&parse_ok(&src)).is_empty(), "{src}");
}
assert!(lint(&parse_ok("[db]\npassword = secret(\"k\")\n")).is_empty());
assert!(lint(&parse_ok("let pw = \"x\"\n[db]\npassword = pw\n")).is_empty());
let token = "aB3xY9qW2eR7tU5iO1pL4zQ8v";
assert!(token.len() >= 20);
let src = format!("note = \"{token}\"\n");
let diags = lint(&parse_ok(&src));
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(!diags[0].message.contains(&token[..8]), "{:?}", diags[0]);
assert!(lint(&parse_ok("note = \"Primary database cluster\"\n")).is_empty());
}
#[test]
fn hardcoded_secrets_cover_arrays() {
let diags = lint(&parse_ok("passwords = [\"s3cr3t-hunter2\"]\n"));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "L004");
assert_eq!(diags[0].path.as_deref(), Some("passwords[0]"));
assert!(!diags[0].message.contains("s3cr3t"), "{:?}", diags[0]);
let token = "zQ8vA1bC3dE5fG7hJ9kL2mN4";
let src = format!("xs = [[\"{token}\"]]\n");
let diags = lint(&parse_ok(&src));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].path.as_deref(), Some("xs[0][0]"));
}
#[test]
fn complexity_counts_env_defaults_and_secret_locators() {
let mut terms = String::from("x = env.FLAG ?: (1");
for _ in 0..100 {
terms.push_str(" + 1");
}
terms.push_str(")\n");
let diags = lint(&parse_ok(&terms));
assert!(diags.iter().any(|d| d.code == "L003"), "{diags:?}");
let mut locator = String::from("x = secret((1");
for _ in 0..100 {
locator.push_str(" + 1");
}
locator.push_str("))\n");
let diags = lint(&parse_ok(&locator));
assert!(diags.iter().any(|d| d.code == "L003"), "{diags:?}");
}
#[test]
fn deprecated_false_is_e020_in_all_forms() {
for src in [
"schema = { x = Int(deprecated = false) }\n",
"schema = { x = Int(deprecated = false), y = String }\n",
"schema = { mode = Enum(\"a\", deprecated = false) }\n",
] {
let diags = parse(src, "lint").expect_err("must fail");
assert!(diags.iter().any(|d| d.code == "E020"), "{diags:?}");
}
}
#[test]
fn entropy_boundary_is_deterministic() {
let src = "note = \"aB3xY9qW2eR7tU5iO1pL4zQ8v\"\n";
let run = || lint_messages(src);
assert_eq!(run(), run());
}
#[test]
fn deprecated_fields_warn_with_migration_message() {
let src =
"schema = { old_port = Int(deprecated = \"use port\"), gone = Bool(deprecated = true) }\n\
old_port = 1\ngone = true\nfresh = 2\n";
let diags = lint(&parse_ok(src));
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| d.code == "L005"));
assert!(diags[0].message.contains("use port"), "{:?}", diags[0]);
assert!(!diags[1].message.contains(':'), "{:?}", diags[1]);
let src = "schema = { old_port = Int(deprecated = true) }\nport = 1\n";
assert!(lint(&parse_ok(src)).is_empty());
for src in [
"schema = { x = Int(deprecated = 42) }\n",
"schema = { x = Int(deprecated = some_let) }\nlet some_let = 1\n",
] {
let diags = parse(src, "lint").expect_err("must fail");
assert!(diags.iter().any(|d| d.code == "E020"), "{diags:?}");
}
let src = "schema = { mode = Enum(\"a\", \"b\", deprecated = \"gone\") }\nmode = \"a\"\n";
let module = parse_ok(src);
let out = active_core::fmt::format(&module, active_core::Style::Brace);
assert!(out.contains("deprecated = \"gone\""), "{out}");
let diags = lint(&parse(&out, "lint").expect("reparse"));
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, "L005");
}
#[test]
fn depends_on_blocks_round_trip_through_the_formatter() {
let src = "[server]\ndepends_on = [\"db\"]\nport = 1\n[db]\nhost = \"h\"\n";
for style in [active_core::Style::Brace, active_core::Style::Indent] {
let out = active_core::fmt::format(&parse_ok(src), style);
assert!(out.contains("depends_on = [\"db\"]"), "kept:\n{out}");
let module = parse(&out, "lint").expect("reparse");
assert!(module.depends_on.values().any(|d| d.paths == ["db"]));
}
}
#[test]
fn rules_disable_independently() {
let src = "let idle = 1\n[db]\npassword = \"s3cr3t-hunter2\"\n";
assert_eq!(lint_codes(src).len(), 2);
for rule in ["unused-lets", "hardcoded-secrets"] {
let options = LintOptions {
disabled: [rule.to_string()].into_iter().collect::<HashSet<_>>(),
};
let codes = lint_with(&parse_ok(src), &options)
.iter()
.map(|d| d.code.clone())
.collect::<Vec<_>>();
assert_eq!(codes.len(), 1, "{rule}: {codes:?}");
}
let options = LintOptions {
disabled: ["unused-lets".to_string(), "hardcoded-secrets".to_string()]
.into_iter()
.collect::<HashSet<_>>(),
};
assert!(lint_with(&parse_ok(src), &options).is_empty());
let err = LintOptions::with_disabled(&["nope".to_string()]).expect_err("must fail");
assert!(err.contains("unused-lets"), "{err}");
}
#[test]
fn lint_output_is_deterministic() {
let src = "let b = 1\nlet a = 2\n[db]\npassword = \"s3cr3t-hunter2\"\n";
let run = || lint_messages(src);
assert_eq!(run(), run());
}
#[test]
fn clean_corpus_and_examples_raise_nothing() {
let mut files = Vec::new();
let dirs = ["tests/positive", "examples"];
for dir in dirs {
let entries = std::fs::read_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(dir))
.unwrap_or_else(|e| panic!("{dir}: {e}"));
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "active") {
files.push(path);
}
}
}
assert!(!files.is_empty(), "corpus must exist");
for path in files {
let module = active_core::parse_file(&path, active_core::ParseOptions::default())
.unwrap_or_else(|d| panic!("{}: {d:?}", path.display()));
let diags = lint(&module);
assert!(diags.is_empty(), "{}: {diags:?}", path.display());
}
}
#[test]
fn chained_and_nested_uses_count_as_used() {
let src = "let a = 1\nlet b = a\nlet idle = 2\n[tbl]\ndeep = [{ v = b }]\nmsg = \"${a}\"\n";
let diags = lint(&parse_ok(src));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "L001");
assert!(diags[0].message.contains("idle"), "{:?}", diags[0]);
}