use crate::command::CommandReport;
use crate::config::{Level, Lint as LintConfig};
use crate::diagnostic::{matches_ignore_action, matches_ignore_workflow};
use crate::domain::file::actions::{ActionSet as WorkflowActionSet, Located as LocatedAction};
use crate::domain::file::parsed::ParsedWorkflow;
use crate::domain::lock::Lock;
use crate::domain::manifest::Manifest;
use crate::output::lines::Line as OutputLine;
pub use crate::diagnostic::RuleName;
pub type Diagnostic = crate::diagnostic::Diagnostic<RuleName>;
pub type Report = crate::diagnostic::Report<RuleName>;
const NO_ISSUES: &str = "No lint issues found";
impl CommandReport for Report {
fn render(&self) -> Vec<OutputLine> {
if self.diagnostics.is_empty() {
return vec![OutputLine::Summary {
text: NO_ISSUES.to_owned(),
}];
}
let mut lines: Vec<OutputLine> = self
.diagnostics
.iter()
.map(|diag| OutputLine::LintDiag {
level: diag.level,
workflow: diag.workflow.as_ref().map(std::string::ToString::to_string),
line: diag.line,
rule: diag.rule.to_string(),
message: diag.message.clone(),
})
.collect();
lines.push(OutputLine::Blank);
lines.push(OutputLine::Summary {
text: self.summary(NO_ISSUES),
});
lines
}
fn exit_code(&self) -> i32 {
Self::exit_code(self)
}
}
pub struct Context<'ctx> {
pub manifest: &'ctx Manifest,
pub lock: &'ctx Lock,
pub workflows: &'ctx [LocatedAction],
pub workflows_full: &'ctx [ParsedWorkflow],
pub action_set: &'ctx WorkflowActionSet,
}
pub trait Rule {
fn name(&self) -> RuleName;
fn default_level(&self) -> Level;
fn check(&self, ctx: &Context) -> Vec<Diagnostic>;
}
#[must_use]
pub fn format_and_report(diagnostics: Vec<Diagnostic>) -> Report {
Report::from_diagnostics(diagnostics)
}
pub(super) fn run_workflow_rule<R: Rule>(
rule: &R,
default_level: Level,
ctx: &Context<'_>,
lint_config: &LintConfig,
out: &mut Vec<Diagnostic>,
) {
let configured = lint_config.get_rule(rule.name(), default_level);
if configured.level == Level::Off {
return;
}
for mut diag in rule.check(ctx) {
diag.level = configured.level;
let ignored = configured
.ignore
.iter()
.any(|target| matches_ignore_workflow(&diag, target));
if !ignored {
out.push(diag);
}
}
}
pub(super) fn is_ignored(
diag: &Diagnostic,
rule_name: RuleName,
default_level: Level,
lint_config: &LintConfig,
action: &LocatedAction,
) -> bool {
lint_config
.get_rule(rule_name, default_level)
.ignore
.iter()
.any(|target| matches_ignore_action(diag, target, action))
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::{CommandReport as _, Diagnostic, Level, OutputLine, Report, Rule as _, RuleName};
use crate::domain::action::identity::{ActionId, CommitDate, CommitSha, Repository, Version};
use crate::domain::action::resolved::{Commit, ResolvedRef};
use crate::domain::action::spec::Spec;
use crate::domain::action::specifier::Specifier;
use crate::domain::action::uses_ref::ParsedRef;
use crate::domain::file::actions::{ActionSet, Located, WorkflowAction};
use crate::domain::file::site::{Id, JobId, Origin, Slot, StepIndex, WorkflowPath};
use crate::domain::lock::Lock;
use crate::domain::manifest::Manifest;
use crate::lint::Context;
use std::collections::HashMap;
use std::str::FromStr as _;
const KIND_NOUNS: [&str; 3] = ["action", "workflow", "component"];
#[test]
fn diagnostic_can_be_created() {
let diag = Diagnostic::new(RuleName::ShaMismatch, Level::Error, "test message");
assert_eq!(diag.rule, RuleName::ShaMismatch);
assert_eq!(diag.level, Level::Error);
assert_eq!(diag.message, "test message");
assert!(diag.workflow.is_none());
}
#[test]
fn diagnostic_with_workflow() {
let diag = Diagnostic::new(RuleName::Unpinned, Level::Warn, "test")
.with_workflow(WorkflowPath::new(".github/workflows/ci.yml"));
assert_eq!(
diag.workflow,
Some(WorkflowPath::new(".github/workflows/ci.yml"))
);
}
#[test]
fn every_rule_name_agrees_across_config_and_output() {
for &name in RuleName::ALL {
let rendered = name.to_string();
let quoted = serde_json::to_string(&name).unwrap();
let configured = quoted.trim_matches('"');
assert_eq!(
rendered,
name.as_str(),
"Display must equal as_str for {name:?}"
);
assert_eq!(
configured, rendered,
"the config name and the reported name must be one string for {name:?}"
);
assert_eq!(
RuleName::from_str(&rendered),
Ok(name),
"the reported name must parse back for {name:?}"
);
assert_eq!(
serde_json::from_str::<RuleName>(&format!("\"{rendered}\"")).unwrap(),
name,
"the reported name must deserialize back for {name:?}"
);
}
}
#[test]
fn all_covers_every_rule_and_names_are_unique() {
let names: std::collections::BTreeSet<&str> =
RuleName::ALL.iter().map(|n| n.as_str()).collect();
assert_eq!(
names.len(),
RuleName::ALL.len(),
"two rules share a name — config could not distinguish them"
);
assert_eq!(RuleName::ALL.len(), 13, "expected 13 lint rules");
}
#[test]
fn rule_name_from_str_invalid() {
RuleName::from_str("nonexistent-rule").unwrap_err();
}
#[test]
fn every_reported_rule_name_is_accepted_in_config() {
let mut toml_str = String::from("[rules]\n");
for name in RuleName::ALL {
use std::fmt::Write as _;
writeln!(toml_str, "{name} = {{ level = \"warn\" }}").unwrap();
}
let config: crate::config::Lint = toml::from_str(&toml_str).unwrap();
assert_eq!(
config.rules.len(),
RuleName::ALL.len(),
"every implemented rule must be configurable by its reported name"
);
for &name in RuleName::ALL {
assert!(
config.rules.contains_key(&name),
"{name} is reported by gx lint but was not accepted in [lint.rules]"
);
}
}
#[test]
fn rule_names_round_trip_as_toml_map_keys() {
let mut rules = std::collections::BTreeMap::new();
for &name in RuleName::ALL {
rules.insert(
name,
crate::config::Rule {
level: Level::Warn,
ignore: Vec::new(),
},
);
}
let emitted = toml::to_string(&rules).unwrap();
for &name in RuleName::ALL {
assert!(
emitted.contains(name.as_str()),
"{name} must survive serialization as a map key"
);
}
let reparsed: std::collections::BTreeMap<RuleName, crate::config::Rule> =
toml::from_str(&emitted).unwrap();
assert_eq!(reparsed.len(), RuleName::ALL.len());
}
#[test]
fn every_rule_has_a_documented_default_level() {
let documented: std::collections::BTreeMap<RuleName, Level> = [
(RuleName::ShaMismatch, Level::Error),
(RuleName::Unpinned, Level::Error),
(RuleName::UnsyncedManifest, Level::Error),
(RuleName::StaleComment, Level::Warn),
(RuleName::MissingPermissions, Level::Error),
(RuleName::ExcessivePermissions, Level::Error),
(RuleName::DangerousTrigger, Level::Error),
(RuleName::PrHeadCheckout, Level::Error),
(RuleName::MissingConcurrency, Level::Warn),
(RuleName::UnprotectedSecrets, Level::Error),
(RuleName::DanglingReference, Level::Error),
(RuleName::InvalidExpression, Level::Error),
(RuleName::RunShellcheck, Level::Warn),
]
.into_iter()
.collect();
for &name in RuleName::ALL {
assert!(
documented.contains_key(&name),
"{name} runs but its zero-config default is not documented"
);
}
assert_eq!(
documented.len(),
RuleName::ALL.len(),
"the documented default set must not list rules that do not exist"
);
}
#[test]
fn unrecognized_rule_name_is_rejected() {
let err = serde_json::from_str::<RuleName>("\"sha-missmatch\"").unwrap_err();
assert!(
err.to_string().contains("sha-missmatch"),
"error must name the offending key, got: {err}"
);
}
#[test]
fn render_lint_clean() {
let report = Report::default();
let lines = report.render();
assert_eq!(lines.len(), 1);
assert!(
matches!(&lines[0], OutputLine::Summary { text } if text == "No lint issues found")
);
}
#[test]
fn render_lint_with_violations() {
let diagnostics = vec![
Diagnostic::new(
RuleName::Unpinned,
Level::Error,
"actions/checkout@main is not pinned",
)
.with_workflow(WorkflowPath::new("ci.yml")),
Diagnostic::new(
RuleName::StaleComment,
Level::Warn,
"version comment does not match lock",
)
.with_workflow(WorkflowPath::new("ci.yml")),
];
let report = Report::from_diagnostics(diagnostics);
let lines = report.render();
assert!(lines.iter().any(|l| matches!(
l,
OutputLine::LintDiag {
level: Level::Error,
..
}
)));
assert!(lines.iter().any(|l| matches!(
l,
OutputLine::LintDiag {
level: Level::Warn,
..
}
)));
assert!(lines.contains(&OutputLine::Summary {
text: "1 error · 1 warning".to_owned(),
}));
}
fn located(reference: ParsedRef) -> Located {
Located {
action: WorkflowAction {
id: ActionId::from("actions/checkout"),
reference,
},
site: Id {
file: WorkflowPath::new(".github/workflows/ci.yml"),
slot: Slot::WorkflowStep {
job: JobId::from("build"),
step: StepIndex::from(0_u16),
},
},
origin: Origin { line: Some(7) },
}
}
fn rule_produced_diagnostics() -> Vec<Diagnostic> {
let sha = CommitSha::from("8e8c483db84b4bee98b60c0593521ed34d9990e8");
let mut out = Vec::new();
out.extend(crate::lint::unpinned::UnpinnedRule::check_action(&located(
ParsedRef::Ref(Version::from("v4")),
)));
let empty_lock = Lock::new(HashMap::new());
out.extend(crate::lint::sha_mismatch::ShaMismatchRule::check_action(
&located(ParsedRef::Sha(sha.clone())),
&empty_lock,
));
let spec = Spec::new(ActionId::from("actions/checkout"), Specifier::from_v1("v4"));
let mut lock = Lock::new(HashMap::new());
lock.set(
&spec,
ResolvedRef::Tag(Version::from("v4")),
Commit {
sha: CommitSha::from("1111111111111111111111111111111111111111"),
repository: Repository::from("actions/checkout"),
ref_type: None,
date: CommitDate::from("2024-01-01T00:00:00Z"),
},
);
out.extend(crate::lint::stale_comment::StaleCommentRule::check_action(
&located(ParsedRef::Pinned {
sha,
comment: Version::from("v4"),
}),
&lock,
));
let mut action_set = ActionSet::new();
action_set.add(&WorkflowAction {
id: ActionId::from("actions/only-in-workflow"),
reference: ParsedRef::Ref(Version::from("v1")),
});
let mut manifest = Manifest::new(HashMap::new());
manifest.set(
ActionId::from("actions/only-in-manifest"),
Specifier::from_v1("v1"),
);
let ctx = Context {
manifest: &manifest,
lock: &empty_lock,
workflows: &[],
workflows_full: &[],
action_set: &action_set,
};
out.extend(crate::lint::unsynced_manifest::UnsyncedManifestRule.check(&ctx));
out
}
#[test]
fn rendered_diagnostics_carry_no_kind_noun() {
let diagnostics = rule_produced_diagnostics();
assert!(
diagnostics.len() >= 5,
"fixture should exercise all four offending rules (unsynced-manifest \
emits two), got {} diagnostics",
diagnostics.len()
);
let report = Report::from_diagnostics(diagnostics);
for line in report.render() {
let OutputLine::LintDiag { message, .. } = line else {
continue;
};
for noun in KIND_NOUNS {
let Some(rest) = message.strip_prefix(noun).and_then(|r| r.strip_prefix(' '))
else {
continue;
};
let labels_an_id = rest
.split_whitespace()
.next()
.is_some_and(|word| word.contains('/'));
assert!(
!labels_an_id,
"rule message must not prefix an identifier with `{noun}` — the \
renderer owns that vocabulary. Got: {message}"
);
}
}
}
}