gx 0.8.5

CLI to manage Github Actions dependencies
use super::rule::{
    Context, Diagnostic, Report, Rule as _, RuleName, format_and_report, is_ignored,
    run_workflow_rule,
};
use super::run_shellcheck::RunShellcheckRule;
use super::sha_mismatch::ShaMismatchRule;
use super::stale_comment::StaleCommentRule;
use super::unpinned::UnpinnedRule;
use super::unsynced_manifest::UnsyncedManifestRule;
use super::workflow_security::{
    DangerousTriggerRule, ExcessivePermissionsRule, MissingConcurrencyRule, MissingPermissionsRule,
    PrHeadCheckoutRule, UnprotectedSecretsRule,
};
use super::workflow_validity::{DanglingReferenceRule, InvalidExpressionRule};
use crate::command::Command;
use crate::config::{Config, Level, Lint as LintConfig};
use crate::diagnostic::matches_ignore;
use crate::domain::file::actions::ActionSet as WorkflowActionSet;
use crate::domain::file::parsed::Parsed;
use crate::domain::file::scan::{Error as WorkflowError, Scanner as WorkflowScanner};
use crate::domain::file::site::{JobId, StepIndex, WorkflowPath};
use crate::domain::lock::Lock;
use crate::domain::manifest::Manifest;
use crate::infra::workflow_scan::FileScanner as FileWorkflowScanner;
use std::path::Path;
use thiserror::Error;

/// Errors that can occur during the lint command.
#[derive(Debug, Error)]
pub enum Error {
    /// A workflow parsing or I/O error occurred.
    #[error(transparent)]
    Workflow(#[from] WorkflowError),
}

/// Run lint checks by scanning workflows and return diagnostics.
///
/// File-local rules (sha-mismatch, unpinned, stale-comment) run per-action during scanning.
/// Global rules (unsynced-manifest) run after the full scan completes.
///
/// # Errors
///
/// Returns [`Error::Workflow`] if a workflow parsing error occurs.
pub fn collect_diagnostics(
    manifest: &Manifest,
    lock: &Lock,
    scanner: &dyn WorkflowScanner,
    lint_config: &LintConfig,
    on_progress: &mut dyn FnMut(&str),
) -> Result<Vec<Diagnostic>, Error> {
    on_progress("Scanning workflows...");
    let sha_mismatch_level = lint_config
        .get_rule(RuleName::ShaMismatch, Level::Error)
        .level;
    let unpinned_level = lint_config.get_rule(RuleName::Unpinned, Level::Error).level;
    let stale_comment_level = lint_config
        .get_rule(RuleName::StaleComment, Level::Warn)
        .level;

    let mut all_diagnostics = Vec::new();
    let mut action_set = WorkflowActionSet::new();

    // Single parse pass yields both per-step action references and the
    // structural Parsed view the workflow-security rules consume.
    let (located, parsed_files) = scanner.scan_all_with_parsed()?;

    // Narrowed once here rather than guarded for in each schema-only rule. This is a total
    // function `Parsed -> Option<ParsedWorkflow>`, not a filter: the rules take
    // `ParsedWorkflow`, so dropping this step is a compile error rather than a silent
    // widening that would let every rule judge an action definition.
    let parsed_workflows: Vec<_> = parsed_files
        .iter()
        .filter_map(Parsed::as_workflow)
        .cloned()
        .collect();

    // Phase 1: per-action rules
    for action in &located {
        if sha_mismatch_level != Level::Off
            && let Some(mut diag) = ShaMismatchRule::check_action(action, lock)
        {
            diag.level = sha_mismatch_level;
            if !is_ignored(
                &diag,
                RuleName::ShaMismatch,
                Level::Error,
                lint_config,
                action,
            ) {
                all_diagnostics.push(diag);
            }
        }
        if unpinned_level != Level::Off
            && let Some(mut diag) = UnpinnedRule::check_action(action)
        {
            diag.level = unpinned_level;
            if !is_ignored(&diag, RuleName::Unpinned, Level::Error, lint_config, action) {
                all_diagnostics.push(diag);
            }
        }
        if stale_comment_level != Level::Off
            && let Some(mut diag) = StaleCommentRule::check_action(action, lock)
        {
            diag.level = stale_comment_level;
            if !is_ignored(
                &diag,
                RuleName::StaleComment,
                Level::Warn,
                lint_config,
                action,
            ) {
                all_diagnostics.push(diag);
            }
        }

        action_set.add(&action.action);
    }

    // Phase 2: action-aggregate rules
    let unsynced_level = lint_config
        .get_rule(RuleName::UnsyncedManifest, Level::Error)
        .level;
    let ctx = Context {
        manifest,
        lock,
        workflows: &located,
        workflows_full: &parsed_workflows,
        action_set: &action_set,
    };
    if unsynced_level != Level::Off {
        let rule = UnsyncedManifestRule;
        for mut diag in rule.check(&ctx) {
            diag.level = unsynced_level;
            let ignored = lint_config
                .get_rule(RuleName::UnsyncedManifest, Level::Error)
                .ignore
                .iter()
                .any(|target| matches_ignore(&diag, target, &located));
            if !ignored {
                all_diagnostics.push(diag);
            }
        }
    }

    // Phase 3: workflow-security rules. Each runs against ctx.workflows_full and emits
    // diagnostics carrying workflow + (optionally) job/step location.
    run_workflow_security_rules(&ctx, lint_config, &mut all_diagnostics);

    // Phase 4: workflow-validity rules. Same parse, same run_workflow_rule path; these
    // catch structurally broken references (dangling needs:, unresolved expressions).
    run_workflow_validity_rules(&ctx, lint_config, &mut all_diagnostics);

    // Phase 5: shellcheck over bash/sh run: bodies. The rule probes for the binary once
    // on construction and degrades gracefully (single skip diagnostic) when it is absent.
    run_workflow_rule(
        &RunShellcheckRule::new(),
        Level::Warn,
        &ctx,
        lint_config,
        &mut all_diagnostics,
    );

    // Stable, location-first ordering so findings for one file read together.
    all_diagnostics.sort_by(|a, b| diagnostic_sort_key(a).cmp(&diagnostic_sort_key(b)));

    Ok(all_diagnostics)
}

/// Output ordering key: group by file, then position within the file, then rule.
/// A missing workflow or job sorts first, so broader findings lead; a missing step
/// sorts last, so a whole-job finding follows the specific steps it covers. Ending
/// on `rule` gives same-location findings a stable, total order.
fn diagnostic_sort_key(diag: &Diagnostic) -> (&str, &str, u16, RuleName) {
    (
        diag.workflow.as_ref().map_or("", WorkflowPath::as_str),
        diag.job.as_ref().map_or("", JobId::as_str),
        diag.step.map_or(u16::MAX, StepIndex::as_u16),
        diag.rule,
    )
}

/// Run all workflow-security rules and append their diagnostics.
fn run_workflow_security_rules(
    ctx: &Context,
    lint_config: &LintConfig,
    all_diagnostics: &mut Vec<Diagnostic>,
) {
    run_workflow_rule(
        &MissingPermissionsRule,
        Level::Error,
        ctx,
        lint_config,
        all_diagnostics,
    );
    run_workflow_rule(
        &ExcessivePermissionsRule,
        Level::Error,
        ctx,
        lint_config,
        all_diagnostics,
    );
    run_workflow_rule(
        &DangerousTriggerRule,
        Level::Error,
        ctx,
        lint_config,
        all_diagnostics,
    );
    run_workflow_rule(
        &PrHeadCheckoutRule,
        Level::Error,
        ctx,
        lint_config,
        all_diagnostics,
    );
    run_workflow_rule(
        &MissingConcurrencyRule,
        Level::Warn,
        ctx,
        lint_config,
        all_diagnostics,
    );
    run_workflow_rule(
        &UnprotectedSecretsRule,
        Level::Error,
        ctx,
        lint_config,
        all_diagnostics,
    );
}

/// Run all workflow-validity rules and append their diagnostics.
fn run_workflow_validity_rules(
    ctx: &Context,
    lint_config: &LintConfig,
    all_diagnostics: &mut Vec<Diagnostic>,
) {
    run_workflow_rule(
        &DanglingReferenceRule,
        Level::Error,
        ctx,
        lint_config,
        all_diagnostics,
    );
    run_workflow_rule(
        &InvalidExpressionRule,
        Level::Error,
        ctx,
        lint_config,
        all_diagnostics,
    );
}

/// The lint command struct.
pub struct Lint;

impl Command for Lint {
    type Report = Report;
    type Error = Error;

    fn run(
        &self,
        repo_root: &Path,
        config: Config,
        on_progress: &mut dyn FnMut(&str),
    ) -> Result<Report, Error> {
        let scanner = FileWorkflowScanner::new(repo_root);

        let diagnostics = collect_diagnostics(
            &config.manifest,
            &config.lock,
            &scanner,
            &config.lint_config,
            on_progress,
        )?;

        Ok(format_and_report(diagnostics))
    }
}