shuck-linter 0.0.43

Lint rule engine and checker for shell scripts
Documentation
pub(crate) mod common;
pub mod correctness;
pub mod performance;
pub mod portability;
pub mod security;
pub mod style;

#[cfg(test)]
mod architecture_tests {
    use std::fs;
    use std::path::Path;

    const RULE_DIRS: &[&str] = &[
        "correctness",
        "performance",
        "portability",
        "security",
        "style",
    ];
    const FORBIDDEN_TOKENS: &[&str] = &[
        "WordPart",
        "WordPartNode",
        "ConditionalExpr",
        "PatternPart",
        "ParameterExpansionSyntax",
        "ZshExpansionTarget",
        "ConditionalCommand",
        "BourneParameterExpansion",
        "iter_commands",
        "query::",
    ];

    #[test]
    fn rule_modules_avoid_direct_ast_traversal_tokens() {
        let rules_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/rules");
        let mut violations = Vec::new();

        for dir in RULE_DIRS {
            let category_dir = rules_root.join(dir);
            let entries = fs::read_dir(&category_dir).unwrap_or_else(|error| {
                panic!("failed to read {}: {error}", category_dir.display())
            });

            for entry in entries {
                let entry = entry.unwrap_or_else(|error| {
                    panic!(
                        "failed to read entry in {}: {error}",
                        category_dir.display()
                    )
                });
                let path = entry.path();
                if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
                    continue;
                }
                if matches!(
                    path.file_name().and_then(|name| name.to_str()),
                    Some("mod.rs" | "syntax.rs")
                ) {
                    continue;
                }

                let source = fs::read_to_string(&path)
                    .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
                for token in FORBIDDEN_TOKENS {
                    if source.contains(token) {
                        violations.push(format!("{} contains `{token}`", path.display()));
                    }
                }
            }
        }

        assert!(
            violations.is_empty(),
            "rule files should rely on fact/shared helper APIs instead of direct AST traversal:\n{}",
            violations.join("\n"),
        );
    }

    #[test]
    fn rules_common_has_no_query_module() {
        let query_module = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/rules/common/query.rs");

        assert!(
            !query_module.exists(),
            "rule-facing traversal helpers must live in facts, not rules/common/query.rs",
        );
    }

    #[test]
    fn facts_traversal_helpers_stay_private() {
        let traversal_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/facts/traversal.rs");
        let source = fs::read_to_string(&traversal_path)
            .unwrap_or_else(|error| panic!("failed to read {}: {error}", traversal_path.display()));
        let forbidden_visibility = [
            "pub(crate) struct CommandVisit",
            "pub(crate) fn visit_arithmetic_words",
            "pub(crate) fn visit_var_ref_subscript_words",
            "pub(crate) fn visit_subscript_words",
        ];
        let violations = forbidden_visibility
            .iter()
            .copied()
            .filter(|token| source.contains(token))
            .collect::<Vec<_>>();

        assert!(
            violations.is_empty(),
            "facts traversal helpers should stay private to the facts module:\n{}",
            violations.join("\n"),
        );
    }

    #[test]
    fn c100_rule_avoids_raw_zsh_option_state_queries() {
        let rule_path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("src/rules/correctness/quoted_bash_source.rs");
        let source = fs::read_to_string(&rule_path)
            .unwrap_or_else(|error| panic!("failed to read {}: {error}", rule_path.display()));
        let forbidden_tokens = [
            "zsh_options_at",
            "zsh_ksh_arrays_runtime_state_at",
            "ZshOptionState",
            "OptionValue",
            "shell_behavior_at",
            "ArrayReferencePolicy",
        ];
        let violations = forbidden_tokens
            .iter()
            .copied()
            .filter(|token| source.contains(token))
            .collect::<Vec<_>>();

        assert!(
            violations.is_empty(),
            "C100 should consume behavior-partitioned facts instead of raw zsh option state:\n{}",
            violations.join("\n"),
        );
    }

    #[test]
    fn glob_sensitive_rules_avoid_raw_zsh_option_state_queries() {
        let rule_paths = [
            ("C012", "correctness/leading_glob_argument.rs"),
            ("K001", "security/rm_glob_on_variable_path.rs"),
            ("S001", "style/unquoted_expansion.rs"),
            ("S001", "style/unquoted_expansion/safe_value.rs"),
            ("S020", "style/single_iteration_loop.rs"),
            ("C055", "correctness/pattern_with_variable.rs"),
            ("C059", "correctness/redirect_to_command_name.rs"),
            ("C078", "correctness/unquoted_globs_in_find.rs"),
            ("C083", "correctness/glob_in_find_substitution.rs"),
            ("C084", "correctness/unquoted_grep_regex.rs"),
            ("C090", "correctness/glob_in_test_comparison.rs"),
            ("C094", "correctness/redirect_clobbers_input.rs"),
            ("C102", "correctness/glob_in_test_directory.rs"),
            ("C114", "correctness/glob_with_expansion_in_loop.rs"),
            ("C128", "correctness/case_glob_reachability.rs"),
            ("C129", "correctness/case_default_before_glob.rs"),
        ];
        let forbidden_tokens = [
            "zsh_options_at",
            "zsh_options()",
            "ZshOptionState",
            "OptionValue",
            "shell_behavior_at",
        ];
        let mut violations = Vec::new();

        for (rule, relative_path) in rule_paths {
            let rule_path = Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("src/rules")
                .join(relative_path);
            let source = fs::read_to_string(&rule_path)
                .unwrap_or_else(|error| panic!("failed to read {}: {error}", rule_path.display()));
            violations.extend(
                forbidden_tokens
                    .iter()
                    .copied()
                    .filter(|token| source.contains(token))
                    .map(|token| format!("{rule} {} contains `{token}`", rule_path.display())),
            );
        }

        assert!(
            violations.is_empty(),
            "glob-sensitive rules should consume behavior-partitioned facts instead of raw zsh option state:\n{}",
            violations.join("\n"),
        );
    }

    #[test]
    fn brace_and_bracket_rules_avoid_raw_zsh_option_state_queries() {
        let rule_paths = [
            "src/rules/correctness/suspicious_bracket_glob.rs",
            "src/rules/portability/brace_expansion.rs",
        ];
        let forbidden_tokens = [
            "zsh_options_at",
            "zsh_options()",
            "ZshOptionState",
            "OptionValue",
            "shell_behavior_at",
            "BraceCharacterClassBehavior",
        ];
        let mut violations = Vec::new();
        for path in rule_paths {
            let rule_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(path);
            let source = fs::read_to_string(&rule_path)
                .unwrap_or_else(|error| panic!("failed to read {}: {error}", rule_path.display()));
            violations.extend(
                forbidden_tokens
                    .iter()
                    .copied()
                    .filter(|token| source.contains(token))
                    .map(|token| format!("{path}: {token}")),
            );
        }

        assert!(
            violations.is_empty(),
            "brace/glob rules should consume option-aware facts instead of raw zsh option state:\n{}",
            violations.join("\n"),
        );
    }
}