Files
glint/internal/linter/keywords.go
T
k3nny f48bf02152 feat(linter): add structured rule IDs (GL001–GL031)
Every Finding now carries a stable Rule string field with a GL### code.
The ID appears in output between the source location and the message:

  [ERROR] job "deploy" (ci.yml:14) GL003: missing required field 'script'
  [WARNING] (ci.yml) GL001: no stages defined

Rules:
  GL001 no-stages          GL002 workflow-when       GL003 missing-script
  GL004 unknown-stage      GL005 only-rules-conflict GL006 except-rules-conflict
  GL007 deprecated-only    GL008 invalid-when        GL009 delayed-no-start-in
  GL010 start-in-no-delayed GL011 invalid-parallel   GL012 invalid-retry
  GL013 invalid-retry-when GL014 invalid-allow-failure GL015 invalid-interruptible
  GL016 trigger-with-script GL017 invalid-trigger    GL018 invalid-coverage
  GL019 invalid-release    GL020 invalid-environment GL021 invalid-artifacts
  GL022 pages-public       GL023 invalid-cache       GL024 invalid-rules-when
  GL025 invalid-image      GL026 invalid-inherit     GL027 needs-unknown
  GL028 needs-stage-order  GL029 needs-cycle         GL030 unknown-dependency
  GL031 dependency-stage

Changes:
- internal/linter/rules.go: new file with all 31 constants + doc comments
- linter.Finding: add Rule string field; String() inserts it before the
  message colon when non-empty; format unchanged when Rule == ""
- All Finding{} literals in linter.go, keywords.go, needs.go,
  dependencies.go updated with the correct Rule: constant
- README.md lint rules table: new ID column added to all four sections
- CHANGELOG.md: entry in [Unreleased]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:56:24 +02:00

527 lines
13 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"
)
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,
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'",
})
}
}
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/
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,
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
}
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
}