gitlab-sim/internal/linter/keywords.go
2026-06-05 01:29:07 +02:00

499 lines
12 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/gitlab-sim/internal/model"
)
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, checkImage(name, job)...)
findings = append(findings, checkInherit(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,
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,
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,
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,
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,
Job: name,
Message: "'parallel' map form must have a 'matrix' key",
}}
}
default:
return []Finding{{
Severity: Error,
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,
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,
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,
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,
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,
Job: name,
Message: "'allow_failure' map form must contain 'exit_codes'",
}}
}
default:
_ = v
return []Finding{{
Severity: Error,
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,
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 len(job.Script) > 0 {
findings = append(findings, Finding{
Severity: Error,
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,
Job: name,
Message: "'trigger' map must specify 'project' or 'include'",
})
}
}
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,
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,
Job: name,
Message: "'release' must be a map",
}}
}
tagName, exists := m["tag_name"]
if !exists || tagName == "" || tagName == nil {
return []Finding{{
Severity: Error,
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,
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,
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,
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,
Job: name,
Message: "'artifacts.expose_as' requires 'artifacts.paths'",
})
}
}
// Pages job should publish to public/
if name == "pages" {
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,
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,
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,
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,
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
}
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,
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,
Job: name,
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
})
}
}
return findings
}