Files
glint/internal/linter/keywords.go
T
k3nnyandClaude Sonnet 4.6 986162d270
ci / vet, staticcheck, test, build (push) Successful in 8m44s
release / Build and publish release (push) Successful in 9m52s
feat(linter): add GL046-GL049 rules, AND-group support, and GL032 fix
- GL046: validate image/service pull_policy values (always, if-not-present, never)
- GL047: error when a variables.options default value is not in the options list
- GL048: error on unrecognised trigger.forward keys
- GL049: validate rules[n].allow_failure (bool or {exit_codes:} map)
- Parse and evaluate workflow.rules/job.rules nested-array AND-groups; crash
  on !!seq nodes is fixed; all members of a group must match for it to fire
- Add workflow.name and workflow.auto_cancel fields to Workflow struct
- Fix GL032 false positive: variables declared in any workflow rule's variables:
  block no longer trigger an undeclared-variable warning in sibling workflow
  rule if: expressions
- Add Windows ARM64 release build target (task build-windows-arm64)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 22:44:22 +02:00

948 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package linter
import (
"fmt"
"regexp"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// dnsLabelPattern matches a valid hostname label: starts and ends with
// alphanumeric, middle may contain hyphens and dots; max 63 chars per label.
var dnsLabelPattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9.-]{0,61}[a-zA-Z0-9])?$`)
// timeoutPattern matches a GitLab CI duration string: one or more pairs of
// "<number> <unit>" where unit is a recognised time word or abbreviation.
var timeoutPattern = regexp.MustCompile(
`(?i)^\s*(\d+\s*(weeks?|w|days?|d|hours?|h|minutes?|mins?|m|seconds?|secs?|s)\s*)+$`)
var validJobWhen = map[string]bool{
"on_success": true,
"on_failure": true,
"always": true,
"manual": true,
"delayed": true,
"never": true,
}
var validRuleWhen = map[string]bool{
"on_success": true,
"on_failure": true,
"always": true,
"manual": true,
"delayed": true,
"never": true,
}
var validWorkflowRuleWhen = map[string]bool{
"always": true,
"never": true,
}
var validArtifactsWhen = map[string]bool{
"on_success": true,
"on_failure": true,
"always": true,
}
var validCacheWhen = map[string]bool{
"on_success": true,
"on_failure": true,
"always": true,
}
var validCachePolicy = map[string]bool{
"pull": true,
"push": true,
"pull-push": true,
}
var validRetryWhen = map[string]bool{
"always": true,
"unknown_failure": true,
"script_failure": true,
"api_failure": true,
"stuck_or_timeout_failure": true,
"runner_system_failure": true,
"runner_unsupported": true,
"stale_schedule": true,
"job_execution_timeout": true,
"archived_failure": true,
"unmet_prerequisites": true,
"scheduler_failure": true,
"data_integrity_failure": true,
"forward_deployment_failure": true,
"insufficient_bridge_permissions": true,
"downstream_bridge_project_not_found": true,
"invalid_bridge_trigger": true,
"upstream_bridge_project_not_found": true,
"protected_environment_failure": true,
"pipeline_loop_detected": true,
"excluded_by_rules": true,
}
var validEnvironmentAction = map[string]bool{
"start": true,
"stop": true,
"prepare": true,
"verify": true,
"access": true,
}
func checkJobKeywords(name string, job model.Job) []Finding {
var findings []Finding
findings = append(findings, checkWhen(name, job)...)
findings = append(findings, checkParallel(name, job)...)
findings = append(findings, checkRetry(name, job)...)
findings = append(findings, checkAllowFailure(name, job)...)
findings = append(findings, checkInterruptible(name, job)...)
findings = append(findings, checkTrigger(name, job)...)
findings = append(findings, checkCoverage(name, job)...)
findings = append(findings, checkRelease(name, job)...)
findings = append(findings, checkEnvironment(name, job)...)
findings = append(findings, checkArtifacts(name, job)...)
findings = append(findings, checkCache(name, job)...)
findings = append(findings, checkRules(name, job)...)
findings = append(findings, checkDeadRules(name, job)...)
findings = append(findings, checkImage(name, job)...)
findings = append(findings, checkInherit(name, job)...)
findings = append(findings, checkServices(name, job)...)
findings = append(findings, checkRulesGlobs(name, job)...)
findings = append(findings, checkTimeout(name, job)...)
findings = append(findings, checkIDTokens(name, job)...)
findings = append(findings, checkSecrets(name, job)...)
findings = append(findings, checkPagesKeyword(name, job)...)
findings = append(findings, checkCacheKeyFiles(name, job)...)
findings = append(findings, checkPullPolicy(name, job)...)
findings = append(findings, checkRulesAllowFailure(name, job)...)
return findings
}
func checkWhen(name string, job model.Job) []Finding {
var findings []Finding
if job.When != "" && !validJobWhen[job.When] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidWhen,
Job: name,
Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When),
})
}
if job.When == "delayed" && job.StartIn == "" {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleDelayedNoStartIn,
Job: name,
Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')",
})
}
if job.When != "delayed" && job.StartIn != "" {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleStartInNoDelayed,
Job: name,
Message: "'start_in' is only valid when 'when: delayed'",
})
}
return findings
}
func checkParallel(name string, job model.Job) []Finding {
if job.Parallel == nil {
return nil
}
switch v := job.Parallel.(type) {
case int:
if v < 2 || v > 200 {
return []Finding{{
Severity: Error,
Rule: RuleInvalidParallel,
Job: name,
Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v),
}}
}
case map[string]any:
if _, ok := v["matrix"]; !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidParallel,
Job: name,
Message: "'parallel' map form must have a 'matrix' key",
}}
}
default:
return []Finding{{
Severity: Error,
Rule: RuleInvalidParallel,
Job: name,
Message: "'parallel' must be an integer (2200) or a map with 'matrix'",
}}
}
return nil
}
func checkRetry(name string, job model.Job) []Finding {
if job.Retry == nil {
return nil
}
switch v := job.Retry.(type) {
case int:
if v < 0 || v > 2 {
return []Finding{{
Severity: Error,
Rule: RuleInvalidRetry,
Job: name,
Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v),
}}
}
case map[string]any:
var findings []Finding
if maxVal, ok := v["max"]; ok {
if n, ok := maxVal.(int); ok && (n < 0 || n > 2) {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRetry,
Job: name,
Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n),
})
}
}
if whenVal, ok := v["when"]; ok {
findings = append(findings, validateRetryWhen(name, whenVal)...)
}
return findings
default:
return []Finding{{
Severity: Error,
Rule: RuleInvalidRetry,
Job: name,
Message: "'retry' must be an integer (02) or a map with 'max'/'when'",
}}
}
return nil
}
func validateRetryWhen(name string, val any) []Finding {
var findings []Finding
check := func(s string) {
if !validRetryWhen[s] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRetryWhen,
Job: name,
Message: fmt.Sprintf("'retry.when' has invalid value %q", s),
})
}
}
switch v := val.(type) {
case string:
check(v)
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
check(s)
}
}
}
return findings
}
func checkAllowFailure(name string, job model.Job) []Finding {
if job.Allow == nil {
return nil
}
switch v := job.Allow.(type) {
case bool:
// valid
case map[string]any:
if _, ok := v["exit_codes"]; !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name,
Message: "'allow_failure' map form must contain 'exit_codes'",
}}
}
default:
_ = v
return []Finding{{
Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name,
Message: "'allow_failure' must be a boolean or a map with 'exit_codes'",
}}
}
return nil
}
func checkInterruptible(name string, job model.Job) []Finding {
if job.Interruptible == nil {
return nil
}
if _, ok := job.Interruptible.(bool); !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidInterruptible,
Job: name,
Message: "'interruptible' must be a boolean",
}}
}
return nil
}
func checkTrigger(name string, job model.Job) []Finding {
if job.Trigger == nil {
return nil
}
var findings []Finding
if scriptNonEmpty(job.Script) {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleTriggerWithScript,
Job: name,
Message: "jobs with 'trigger' cannot use 'script'",
})
}
if m, ok := job.Trigger.(map[string]any); ok {
_, hasProject := m["project"]
_, hasInclude := m["include"]
if !hasProject && !hasInclude {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidTrigger,
Job: name,
Message: "'trigger' map must specify 'project' or 'include'",
})
}
// GL048: validate trigger.forward keys.
if fwd, ok := m["forward"].(map[string]any); ok {
validForwardKeys := map[string]bool{"pipeline_variables": true, "yaml_variables": true}
for k := range fwd {
if !validForwardKeys[k] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidTriggerForward,
Job: name,
Message: fmt.Sprintf("'trigger.forward' has unrecognised key %q; valid keys: pipeline_variables, yaml_variables", k),
})
}
}
}
}
return findings
}
var coveragePattern = regexp.MustCompile(`^/.*/$`)
func checkCoverage(name string, job model.Job) []Finding {
if job.Coverage == "" {
return nil
}
if !coveragePattern.MatchString(job.Coverage) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidCoverage,
Job: name,
Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage),
}}
}
return nil
}
func checkRelease(name string, job model.Job) []Finding {
if job.Release == nil {
return nil
}
m, ok := job.Release.(map[string]any)
if !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidRelease,
Job: name,
Message: "'release' must be a map",
}}
}
tagName, exists := m["tag_name"]
if !exists || tagName == "" || tagName == nil {
return []Finding{{
Severity: Error,
Rule: RuleInvalidRelease,
Job: name,
Message: "'release' requires 'tag_name'",
}}
}
return nil
}
func checkEnvironment(name string, job model.Job) []Finding {
if job.Environment == nil {
return nil
}
m, ok := job.Environment.(map[string]any)
if !ok {
// String form: just the environment name — valid.
return nil
}
var findings []Finding
envName := m["name"]
_, hasURL := m["url"]
if (envName == nil || envName == "") && hasURL {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name,
Message: "'environment.url' requires 'environment.name' to be set",
})
}
if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name,
Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action),
})
}
return findings
}
func checkArtifacts(name string, job model.Job) []Finding {
if job.Artifacts == nil {
return nil
}
m, ok := job.Artifacts.(map[string]any)
if !ok {
return nil
}
var findings []Finding
if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name,
Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w),
})
}
if _, hasExposeAs := m["expose_as"]; hasExposeAs {
paths := m["paths"]
if paths == nil {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name,
Message: "'artifacts.expose_as' requires 'artifacts.paths'",
})
}
}
// Pages job should publish to public/. Skip when pages: keyword is set;
// GL039 (checkPagesKeyword) handles that case with a more precise check.
if name == "pages" && job.Pages == nil {
if paths, ok := m["paths"].([]any); ok {
found := false
for _, p := range paths {
if s, ok := p.(string); ok && (s == "public" || strings.HasPrefix(s, "public/")) {
found = true
break
}
}
if !found {
findings = append(findings, Finding{
Severity: Warning,
Rule: RulePagesPublic,
Job: name,
Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy",
})
}
}
}
return findings
}
func checkCache(name string, job model.Job) []Finding {
if job.Cache == nil {
return nil
}
var maps []map[string]any
switch v := job.Cache.(type) {
case map[string]any:
maps = []map[string]any{v}
case []any:
for _, item := range v {
if m, ok := item.(map[string]any); ok {
maps = append(maps, m)
}
}
}
var findings []Finding
for _, m := range maps {
if w, ok := m["when"].(string); ok && !validCacheWhen[w] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidCache,
Job: name,
Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w),
})
}
if p, ok := m["policy"].(string); ok && !validCachePolicy[p] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidCache,
Job: name,
Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p),
})
}
}
return findings
}
func checkRules(name string, job model.Job) []Finding {
var findings []Finding
for i, rule := range job.Rules {
if rule.When != "" && !validRuleWhen[rule.When] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesWhen,
Job: name,
Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When),
})
}
}
return findings
}
// checkDeadRules reports when every rule in a job's rules: block has an
// explicit when: never, making the job permanently unreachable. This is a
// provably-correct static claim: no matter which if: condition matches, the
// outcome is always "never"; and if no rule matches, the implicit fallback is
// also skip. No if: evaluation is required.
func checkDeadRules(name string, job model.Job) []Finding {
if len(job.Rules) == 0 {
return nil
}
for _, r := range job.Rules {
if r.When != "never" {
return nil
}
}
return []Finding{{
Severity: Warning,
Rule: RuleDeadRules,
Job: name,
Message: "rules: block can never activate; every rule has 'when: never' — job is permanently excluded from the pipeline",
}}
}
func checkImage(name string, job model.Job) []Finding {
if job.Image == nil {
return nil
}
m, ok := job.Image.(map[string]any)
if !ok {
return nil // String form is valid.
}
imgName := m["name"]
if imgName == nil || imgName == "" {
return []Finding{{
Severity: Error,
Rule: RuleInvalidImage,
Job: name,
Message: "'image' map form requires a 'name' key",
}}
}
return nil
}
func checkInherit(name string, job model.Job) []Finding {
if job.Inherit == nil {
return nil
}
m, ok := job.Inherit.(map[string]any)
if !ok {
return nil
}
var findings []Finding
for _, key := range []string{"default", "variables"} {
val, exists := m[key]
if !exists {
continue
}
switch val.(type) {
case bool, []any:
// valid: false/true or list of names
default:
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidInherit,
Job: name,
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
})
}
}
return findings
}
// GL034: services: map form requires 'name'; 'alias' must be a valid DNS label.
func checkServices(name string, job model.Job) []Finding {
if len(job.Services) == 0 {
return nil
}
var findings []Finding
for i, svc := range job.Services {
m, ok := svc.(map[string]any)
if !ok {
continue // string form is valid
}
n := m["name"]
if n == nil || n == "" {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidService,
Job: name,
Message: fmt.Sprintf("services[%d]: map form requires a 'name' key", i),
})
}
if alias, ok := m["alias"].(string); ok && alias != "" {
if !dnsLabelPattern.MatchString(alias) {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidService,
Job: name,
Message: fmt.Sprintf("services[%d]: alias %q is not a valid DNS label (only letters, digits, hyphens, and dots; must start and end with alphanumeric)", i, alias),
})
}
}
}
return findings
}
// GL035: rules:changes and rules:exists paths must not be absolute.
func checkRulesGlobs(name string, job model.Job) []Finding {
var findings []Finding
for i, rule := range job.Rules {
findings = append(findings, checkAbsolutePaths(name, fmt.Sprintf("rules[%d].changes", i), rule.Changes)...)
findings = append(findings, checkAbsolutePaths(name, fmt.Sprintf("rules[%d].exists", i), rule.Exists)...)
}
return findings
}
func checkAbsolutePaths(name, field string, val any) []Finding {
var paths []string
switch v := val.(type) {
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
paths = append(paths, s)
}
}
case map[string]any:
if ps, ok := v["paths"].([]any); ok {
for _, item := range ps {
if s, ok := item.(string); ok {
paths = append(paths, s)
}
}
}
}
var findings []Finding
for _, p := range paths {
if strings.HasPrefix(p, "/") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleAbsoluteGlobPath,
Job: name,
Message: fmt.Sprintf("%s: path %q is absolute; GitLab CI paths are relative to the repository root — absolute paths will never match", field, p),
})
}
}
return findings
}
// GL036: timeout: must be a valid GitLab CI duration string.
func checkTimeout(name string, job model.Job) []Finding {
if job.Timeout == "" {
return nil
}
if !timeoutPattern.MatchString(job.Timeout) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidTimeout,
Job: name,
Message: fmt.Sprintf("'timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'", job.Timeout),
}}
}
return nil
}
// CheckDefaultTimeout validates the pipeline-level default: timeout: field.
// Exported so it can be called from linter.go.
func checkDefaultTimeout(timeout, file string) []Finding {
if timeout == "" {
return nil
}
if !timeoutPattern.MatchString(timeout) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidTimeout,
File: file,
Message: fmt.Sprintf("'default.timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'", timeout),
}}
}
return nil
}
// GL037: id_tokens: each entry must have an 'aud' key.
func checkIDTokens(name string, job model.Job) []Finding {
if job.IDTokens == nil {
return nil
}
m, ok := job.IDTokens.(map[string]any)
if !ok {
return nil
}
var findings []Finding
for tokenName, tokenVal := range m {
tokenMap, ok := tokenVal.(map[string]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidIDToken,
Job: name,
Message: fmt.Sprintf("id_tokens.%s: must be a map with an 'aud' key", tokenName),
})
continue
}
if _, hasAud := tokenMap["aud"]; !hasAud {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidIDToken,
Job: name,
Message: fmt.Sprintf("id_tokens.%s: missing required 'aud' key", tokenName),
})
}
}
return findings
}
// GL038: secrets: each entry must have a provider key (vault, gcp_secret_manager, azure_key_vault).
func checkSecrets(name string, job model.Job) []Finding {
if job.Secrets == nil {
return nil
}
m, ok := job.Secrets.(map[string]any)
if !ok {
return nil
}
validProviders := []string{"vault", "gcp_secret_manager", "azure_key_vault"}
var findings []Finding
for secretName, secretVal := range m {
secretMap, ok := secretVal.(map[string]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidSecret,
Job: name,
Message: fmt.Sprintf("secrets.%s: must be a map with a provider key (vault, gcp_secret_manager, or azure_key_vault)", secretName),
})
continue
}
hasProvider := false
for _, provider := range validProviders {
if _, ok := secretMap[provider]; ok {
hasProvider = true
break
}
}
if !hasProvider {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidSecret,
Job: name,
Message: fmt.Sprintf("secrets.%s: missing provider key; must include one of: vault, gcp_secret_manager, azure_key_vault", secretName),
})
}
}
return findings
}
// GL039: a job with the pages: keyword must have the publish directory in artifacts.paths.
func checkPagesKeyword(name string, job model.Job) []Finding {
if job.Pages == nil {
return nil
}
publishDir := "public"
if m, ok := job.Pages.(map[string]any); ok {
if p, ok := m["publish"].(string); ok && p != "" {
publishDir = p
}
}
if job.Artifacts == nil {
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("job uses 'pages:' keyword with publish dir %q but has no 'artifacts:' block — GitLab Pages will not deploy", publishDir),
}}
}
m, ok := job.Artifacts.(map[string]any)
if !ok {
return nil
}
paths, ok := m["paths"].([]any)
if !ok || len(paths) == 0 {
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("job uses 'pages:' keyword with publish dir %q but 'artifacts.paths' is missing — GitLab Pages will not deploy", publishDir),
}}
}
for _, p := range paths {
if s, ok := p.(string); ok && (s == publishDir || strings.HasPrefix(s, publishDir+"/")) {
return nil
}
}
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("'pages.publish' is %q but 'artifacts.paths' does not include it — GitLab Pages will not deploy", publishDir),
}}
}
// GL046: image.pull_policy and services[n].pull_policy must use recognised values.
var validPullPolicy = map[string]bool{
"always": true, "if-not-present": true, "never": true,
}
func checkPullPolicy(name string, job model.Job) []Finding {
var findings []Finding
findings = append(findings, checkPullPolicyValue(name, "image", job.Image)...)
for i, svc := range job.Services {
findings = append(findings, checkPullPolicyValue(name, fmt.Sprintf("services[%d]", i), svc)...)
}
return findings
}
func checkPullPolicyValue(jobName, field string, v any) []Finding {
m, ok := v.(map[string]any)
if !ok {
return nil
}
pp, exists := m["pull_policy"]
if !exists || pp == nil {
return nil
}
var policies []string
switch x := pp.(type) {
case string:
policies = []string{x}
case []any:
for _, item := range x {
if s, ok := item.(string); ok {
policies = append(policies, s)
}
}
}
var findings []Finding
for _, p := range policies {
if !validPullPolicy[p] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidPullPolicy,
Job: jobName,
Message: fmt.Sprintf("%s.pull_policy has unrecognised value %q; valid: always, if-not-present, never", field, p),
})
}
}
return findings
}
// GL049: rules[n].allow_failure must be a boolean or a map with exit_codes:.
func checkRulesAllowFailure(name string, job model.Job) []Finding {
var findings []Finding
for i, rule := range job.Rules {
if rule.AllowFailure == nil {
continue
}
switch v := rule.AllowFailure.(type) {
case bool:
// valid
case map[string]any:
if _, ok := v["exit_codes"]; !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesAllowFailure,
Job: name,
Message: fmt.Sprintf("rules[%d].allow_failure map form must contain 'exit_codes'", i),
})
}
default:
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesAllowFailure,
Job: name,
Message: fmt.Sprintf("rules[%d].allow_failure must be a boolean or a map with 'exit_codes'", i),
})
}
}
return findings
}
// GL041: cache.key.files must be a list of exact file paths, not glob patterns.
func checkCacheKeyFiles(name string, job model.Job) []Finding {
if job.Cache == nil {
return nil
}
var maps []map[string]any
switch v := job.Cache.(type) {
case map[string]any:
maps = []map[string]any{v}
case []any:
for _, item := range v {
if m, ok := item.(map[string]any); ok {
maps = append(maps, m)
}
}
}
var findings []Finding
for _, m := range maps {
keyVal, ok := m["key"]
if !ok {
continue
}
keyMap, ok := keyVal.(map[string]any)
if !ok {
continue
}
filesVal, ok := keyMap["files"]
if !ok {
continue
}
filesList, ok := filesVal.([]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidCacheKeyFiles,
Job: name,
Message: "'cache.key.files' must be a list of file paths",
})
continue
}
for _, item := range filesList {
s, ok := item.(string)
if !ok {
continue
}
if strings.ContainsAny(s, "*?[") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleInvalidCacheKeyFiles,
Job: name,
Message: fmt.Sprintf("cache.key.files: %q looks like a glob pattern; 'key.files' must be exact file paths, not globs", s),
})
}
}
}
return findings
}