use bashrs::linter::code_namespace;
use std::collections::HashMap;
fn shellcheck_registry() -> HashMap<String, String> {
let raw = include_str!("data/shellcheck-registry.tsv");
raw.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.filter_map(|l| l.split_once('\t'))
.map(|(c, m)| (c.to_string(), m.to_string()))
.collect()
}
fn bashrs_sc_rule_modules() -> Vec<String> {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/linter/rules");
let mut out: Vec<String> = std::fs::read_dir(dir)
.expect("rules dir")
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter_map(|n| n.strip_suffix(".rs").map(str::to_string))
.filter(|n| {
n.len() == 6 && n.starts_with("sc") && n[2..].chars().all(|c| c.is_ascii_digit())
})
.filter(|n| {
std::fs::read_to_string(format!("{dir}/{n}.rs"))
.map(|s| s.contains("Diagnostic::new"))
.unwrap_or(false)
})
.map(|n| n.to_uppercase())
.collect();
out.sort();
out.dedup();
out
}
#[test]
fn no_bashrs_check_squats_on_a_shellcheck_code() {
let registry = shellcheck_registry();
let mut squatters = Vec::new();
for code in bashrs_sc_rule_modules() {
if code_namespace::canonical(&code) != code {
continue;
}
if code_namespace::is_retired(&code) {
continue;
}
let Some(sc_msg) = registry.get(&code) else {
continue;
};
if code_namespace::is_parity(&code) {
continue;
}
squatters.push(format!(" {code} ShellCheck: {sc_msg}"));
}
assert!(
squatters.is_empty(),
"these bashrs checks are filed under a ShellCheck code that means \
something else.\nGive each one a BRS#### code in \
linter::code_namespace::MIGRATIONS, or — if it really does mean the \
same thing as ShellCheck's check — add it to PARITY with the \
ShellCheck message quoted:\n{}",
squatters.join("\n")
);
}
#[test]
fn migration_table_is_injective_and_idempotent() {
let mut seen: HashMap<&str, &str> = HashMap::new();
for (legacy, new) in code_namespace::MIGRATIONS {
assert!(
new.starts_with("BRS"),
"{legacy} must migrate into the BRS namespace, got {new}"
);
assert_eq!(
code_namespace::canonical(new),
*new,
"{new} must be a fixed point of canonical()"
);
assert_eq!(code_namespace::canonical(legacy), *new);
if let Some(prev) = seen.insert(new, legacy) {
panic!("{new} is claimed by both {prev} and {legacy}");
}
}
}
#[test]
fn canonical_is_identity_for_untouched_codes() {
for code in [
"SC2086",
"SEC011",
"DET002",
"IDEM002",
"PERF002",
"BASHRS001",
] {
assert_eq!(code_namespace::canonical(code), code);
}
}
#[test]
fn migration_retirement_and_parity_tables_are_disjoint() {
for (legacy, new) in code_namespace::MIGRATIONS {
assert!(
!code_namespace::is_parity(legacy),
"{legacy} is migrated to {new} AND listed as parity"
);
assert!(
!code_namespace::is_retired(legacy),
"{legacy} is migrated to {new} AND retired"
);
}
for (code, _) in code_namespace::RETIRED {
assert!(
!code_namespace::is_parity(code),
"{code} is retired AND listed as parity"
);
}
}
#[test]
fn every_parity_claim_names_a_code_shellcheck_actually_uses() {
let registry = shellcheck_registry();
let unbacked: Vec<&&str> = code_namespace::PARITY
.iter()
.filter(|c| !registry.contains_key(**c))
.collect();
assert!(
unbacked.is_empty(),
"PARITY claims equivalence with a ShellCheck check that was never \
measured: {unbacked:?}. Either measure it (append to \
tests/data/shellcheck-registry.tsv with the snippet that triggered \
it) or drop the claim."
);
}
use bashrs::linter::lint_shell;
fn codes(src: &str) -> Vec<String> {
lint_shell(src)
.diagnostics
.iter()
.map(|d| d.code.clone())
.collect()
}
#[test]
fn migrated_checks_still_fire_under_their_new_code() {
let cases: &[(&str, &str, &str)] = &[
("#!/bin/bash\nmsg=\"hello world\"\n", "SC2311", "BRS0026"),
("#!/bin/bash\nrm -rf \"$dir\"\n", "SC2114", "BRS0011"),
("#!/bin/bash\necho 'value is $HOME'\n", "SC2081", "BRS0006"),
(
"#!/bin/bash\ncount=$(grep -c x f > out | wc -l)\n",
"SC2227",
"BRS0018",
),
];
for (src, legacy, new) in cases {
let found = codes(src);
assert!(
found.iter().any(|c| c == new),
"{new} (was {legacy}) stopped firing on:\n{src}\ngot: {found:?}"
);
assert!(
!found.iter().any(|c| c == legacy),
"{legacy} is still emitted — the collision was not actually fixed"
);
}
}
#[test]
fn retiring_sc2032_did_not_silence_the_security_and_determinism_rules() {
let src = "#!/bin/bash\n\
src_raw=/data/raw\n\
dst=/data/out\n\
stamp=$(date +%s)\n\
rm -rf \"$src_raw\"\n\
eval \"$UNTRUSTED\"\n";
let found = codes(src);
assert!(
!found.iter().any(|c| c == "SC2032"),
"SC2032 is retired but still emitted: {found:?}"
);
for family in ["SEC", "DET"] {
assert!(
found.iter().any(|c| c.starts_with(family)),
"{family}* stopped firing after the SC2032 retirement: {found:?}"
);
}
}
#[test]
fn a_bashrs_pragma_naming_the_legacy_code_still_suppresses() {
let src = "#!/bin/bash\n# bashrs disable=SC2081\necho 'value is $HOME'\n";
assert!(
!codes(src).iter().any(|c| c == "BRS0006"),
"a pre-migration `# bashrs disable=SC2081` stopped suppressing"
);
let src_new = "#!/bin/bash\n# bashrs disable=BRS0006\necho 'value is $HOME'\n";
assert!(!codes(src_new).iter().any(|c| c == "BRS0006"));
}
#[test]
fn a_shellcheck_pragma_does_not_suppress_the_bashrs_check_that_squatted_on_it() {
let src = "#!/bin/bash\n# shellcheck disable=SC2081\necho 'value is $HOME'\n";
assert!(
codes(src).iter().any(|c| c == "BRS0006"),
"`# shellcheck disable=SC2081` still silences a bashrs check that has \
nothing to do with ShellCheck's SC2081"
);
}
#[test]
fn a_bashrsignore_naming_the_legacy_code_still_ignores() {
use bashrs::linter::IgnoreFile;
let ignore = IgnoreFile::parse("SC2081\n").expect("valid ignore file");
assert!(
ignore.should_ignore_rule("BRS0006"),
"a pre-migration `.bashrsignore` naming SC2081 stopped ignoring BRS0006"
);
assert!(IgnoreFile::parse("BRS0006\n")
.unwrap()
.should_ignore_rule("BRS0006"));
assert!(!ignore.should_ignore_rule("SEC011"));
}