From f48bf021523f8c5b84b94990c686195ef052d928 Mon Sep 17 00:00:00 2001 From: k3nny Date: Thu, 11 Jun 2026 22:56:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(linter):=20add=20structured=20rule=20IDs?= =?UTF-8?q?=20(GL001=E2=80=93GL031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 + README.md | 87 ++++++++++++------------- internal/linter/dependencies.go | 2 + internal/linter/keywords.go | 28 ++++++++ internal/linter/linter.go | 20 ++++-- internal/linter/needs.go | 3 + internal/linter/rules.go | 110 ++++++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 50 deletions(-) create mode 100644 internal/linter/rules.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4bc22..7af0ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ This project uses [Semantic Versioning](https://semver.org). ### Added +- **Structured rule IDs** — every finding now carries a stable `GL###` identifier (e.g. `GL003`) that appears in the output between the location and the message: `[ERROR] job "deploy" (file.yml:14) GL003: missing required field 'script'`. IDs are assigned per check function across 31 rules (GL001–GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns. + - **`workflow:rules:variables:` now propagate to job rule evaluation** — when a `workflow:rules:` entry matches, any `variables:` it defines are injected into the evaluation context so job `rules:if:` expressions can reference them. Pipeline-level `variables:` are also available as defaults (lower priority). Variable priority order, highest first: `--var` CLI overrides → `--branch`/`--tag`/`--source` shortcuts → workflow-rule variables → pipeline-level variable defaults. This means `$DEPLOY_TARGET == "production"` in a job rule correctly evaluates when a workflow rule sets `DEPLOY_TARGET: production` for the matching branch. The `glint graph tree` command benefits from the same enrichment. - **`rules:if:` expression evaluator improvements** — six correctness fixes to the GitLab CI expression parser: diff --git a/README.md b/README.md index 358eb71..5696e87 100644 --- a/README.md +++ b/README.md @@ -245,63 +245,58 @@ OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) ## Lint rules +Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filter output or reference a specific check in documentation. + ### Pipeline-level -| Severity | Rule | -|----------|------| -| ERROR | `workflow.rules[*].when` is not `always` or `never` | -| WARNING | No `stages` defined (GitLab falls back to default stages) | +| ID | Severity | Rule | +|----|----------|------| +| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` | +| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) | ### Job-level — structure -| Severity | Rule | -|----------|------| -| ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs | -| ERROR | Job references a `stage` not declared in `stages` | -| ERROR | `only` and `rules` used together on the same job | -| ERROR | `except` and `rules` used together on the same job | -| WARNING | `only`/`except` used (deprecated, prefer `rules`) | +| ID | Severity | Rule | +|----|----------|------| +| GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs | +| GL004 | ERROR | Job references a `stage` not declared in `stages` | +| GL005 | ERROR | `only` and `rules` used together on the same job | +| GL006 | ERROR | `except` and `rules` used together on the same job | +| GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) | ### Job-level — keyword constraints -| Severity | Rule | -|----------|------| -| ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` | -| ERROR | `when: delayed` without `start_in` | -| ERROR | `start_in` set but `when` is not `delayed` | -| ERROR | `parallel` integer not in range 2–200 | -| ERROR | `parallel` map form missing `matrix` key | -| ERROR | `retry` integer not in range 0–2 | -| ERROR | `retry.max` not in range 0–2 | -| ERROR | `retry.when` contains an invalid failure type | -| ERROR | `allow_failure` is not a boolean or a map with `exit_codes` | -| ERROR | `interruptible` is not a boolean | -| ERROR | `trigger` job also has `script` | -| ERROR | `trigger` map missing `project` or `include` | -| ERROR | `coverage` is not a regex pattern wrapped in `/` | -| ERROR | `release` missing required `tag_name` | -| ERROR | `environment.url` set without `environment.name` | -| ERROR | `environment.action` is not one of `start`, `stop`, `prepare`, `verify`, `access` | -| ERROR | `artifacts.when` is not `on_success`, `on_failure`, or `always` | -| ERROR | `artifacts.expose_as` set without `artifacts.paths` | -| ERROR | `cache.when` is not `on_success`, `on_failure`, or `always` | -| ERROR | `cache.policy` is not `pull`, `push`, or `pull-push` | -| ERROR | `rules[*].when` is not one of the valid `when` values | -| ERROR | `image` map form missing `name` key | -| ERROR | `inherit.default` / `inherit.variables` is not a boolean or list | -| WARNING | `pages` job `artifacts.paths` does not include `public` | +| ID | Severity | Rule | +|----|----------|------| +| GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` | +| GL009 | ERROR | `when: delayed` without `start_in` | +| GL010 | ERROR | `start_in` set but `when` is not `delayed` | +| GL011 | ERROR | `parallel` integer not in range 2–200, or map form missing `matrix` key | +| GL012 | ERROR | `retry` integer not in range 0–2, or `retry.max` out of range | +| GL013 | ERROR | `retry.when` contains an unrecognised failure type | +| GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` | +| GL015 | ERROR | `interruptible` is not a boolean | +| GL016 | ERROR | `trigger` job also has `script` | +| GL017 | ERROR | `trigger` map missing `project` or `include` | +| GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` | +| GL019 | ERROR | `release` missing required `tag_name`, or is not a map | +| GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` | +| GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` | +| GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` | +| GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value | +| GL024 | ERROR | `rules[*].when` is not one of the valid `when` values | +| GL025 | ERROR | `image` map form missing `name` key | +| GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list | ### Cross-job graph -| Severity | Rule | -|----------|------| -| ERROR | `needs:` references a job that does not exist | -| ERROR | `needs:` references a job in a later stage | -| ERROR | Circular dependency detected in `needs:` graph | -| ERROR | `dependencies:` references a job that does not exist | -| ERROR | `dependencies:` references a job in the same or a later stage | -| WARNING | `extends:` references an unknown base job (resolver warning; extends chain skipped for that job) | -| ERROR | Cycle detected in `extends:` graph | +| ID | Severity | Rule | +|----|----------|------| +| GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) | +| GL028 | ERROR | `needs:` references a job in a later stage | +| GL029 | ERROR | Circular dependency detected in `needs:` graph | +| GL030 | ERROR | `dependencies:` references a job that does not exist | +| GL031 | ERROR | `dependencies:` references a job in the same or a later stage | ### Hidden jobs (templates) diff --git a/internal/linter/dependencies.go b/internal/linter/dependencies.go index 51c641b..dfa00de 100644 --- a/internal/linter/dependencies.go +++ b/internal/linter/dependencies.go @@ -23,6 +23,7 @@ func checkDependencies(p *model.Pipeline) []Finding { if !exists { findings = append(findings, Finding{ Severity: Error, + Rule: RuleUnknownDependency, Job: name, File: job.File, Line: job.Line, @@ -35,6 +36,7 @@ func checkDependencies(p *model.Pipeline) []Finding { if depHasStage && depIdx >= jobStageIdx { findings = append(findings, Finding{ Severity: Error, + Rule: RuleDependencyStage, Job: name, File: job.File, Line: job.Line, diff --git a/internal/linter/keywords.go b/internal/linter/keywords.go index e21d739..d628d09 100644 --- a/internal/linter/keywords.go +++ b/internal/linter/keywords.go @@ -105,6 +105,7 @@ func checkWhen(name string, job model.Job) []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), }) @@ -112,6 +113,7 @@ func checkWhen(name string, job model.Job) []Finding { 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')", }) @@ -119,6 +121,7 @@ func checkWhen(name string, job model.Job) []Finding { 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'", }) @@ -135,6 +138,7 @@ func checkParallel(name string, job model.Job) []Finding { 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), }} @@ -143,6 +147,7 @@ func checkParallel(name string, job model.Job) []Finding { if _, ok := v["matrix"]; !ok { return []Finding{{ Severity: Error, + Rule: RuleInvalidParallel, Job: name, Message: "'parallel' map form must have a 'matrix' key", }} @@ -150,6 +155,7 @@ func checkParallel(name string, job model.Job) []Finding { default: return []Finding{{ Severity: Error, + Rule: RuleInvalidParallel, Job: name, Message: "'parallel' must be an integer (2–200) or a map with 'matrix'", }} @@ -166,6 +172,7 @@ func checkRetry(name string, job model.Job) []Finding { 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), }} @@ -176,6 +183,7 @@ func checkRetry(name string, job model.Job) []Finding { 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), }) @@ -188,6 +196,7 @@ func checkRetry(name string, job model.Job) []Finding { default: return []Finding{{ Severity: Error, + Rule: RuleInvalidRetry, Job: name, Message: "'retry' must be an integer (0–2) or a map with 'max'/'when'", }} @@ -201,6 +210,7 @@ func validateRetryWhen(name string, val any) []Finding { if !validRetryWhen[s] { findings = append(findings, Finding{ Severity: Error, + Rule: RuleInvalidRetryWhen, Job: name, Message: fmt.Sprintf("'retry.when' has invalid value %q", s), }) @@ -230,6 +240,7 @@ func checkAllowFailure(name string, job model.Job) []Finding { if _, ok := v["exit_codes"]; !ok { return []Finding{{ Severity: Error, + Rule: RuleInvalidAllowFailure, Job: name, Message: "'allow_failure' map form must contain 'exit_codes'", }} @@ -238,6 +249,7 @@ func checkAllowFailure(name string, job model.Job) []Finding { _ = v return []Finding{{ Severity: Error, + Rule: RuleInvalidAllowFailure, Job: name, Message: "'allow_failure' must be a boolean or a map with 'exit_codes'", }} @@ -252,6 +264,7 @@ func checkInterruptible(name string, job model.Job) []Finding { if _, ok := job.Interruptible.(bool); !ok { return []Finding{{ Severity: Error, + Rule: RuleInvalidInterruptible, Job: name, Message: "'interruptible' must be a boolean", }} @@ -267,6 +280,7 @@ func checkTrigger(name string, job model.Job) []Finding { if scriptNonEmpty(job.Script) { findings = append(findings, Finding{ Severity: Error, + Rule: RuleTriggerWithScript, Job: name, Message: "jobs with 'trigger' cannot use 'script'", }) @@ -277,6 +291,7 @@ func checkTrigger(name string, job model.Job) []Finding { if !hasProject && !hasInclude { findings = append(findings, Finding{ Severity: Error, + Rule: RuleInvalidTrigger, Job: name, Message: "'trigger' map must specify 'project' or 'include'", }) @@ -294,6 +309,7 @@ func checkCoverage(name string, job model.Job) []Finding { 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), }} @@ -309,6 +325,7 @@ func checkRelease(name string, job model.Job) []Finding { if !ok { return []Finding{{ Severity: Error, + Rule: RuleInvalidRelease, Job: name, Message: "'release' must be a map", }} @@ -317,6 +334,7 @@ func checkRelease(name string, job model.Job) []Finding { if !exists || tagName == "" || tagName == nil { return []Finding{{ Severity: Error, + Rule: RuleInvalidRelease, Job: name, Message: "'release' requires 'tag_name'", }} @@ -339,6 +357,7 @@ func checkEnvironment(name string, job model.Job) []Finding { if (envName == nil || envName == "") && hasURL { findings = append(findings, Finding{ Severity: Error, + Rule: RuleInvalidEnvironment, Job: name, Message: "'environment.url' requires 'environment.name' to be set", }) @@ -346,6 +365,7 @@ func checkEnvironment(name string, job model.Job) []Finding { 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), }) @@ -365,6 +385,7 @@ func checkArtifacts(name string, job model.Job) []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), }) @@ -374,6 +395,7 @@ func checkArtifacts(name string, job model.Job) []Finding { if paths == nil { findings = append(findings, Finding{ Severity: Error, + Rule: RuleInvalidArtifacts, Job: name, Message: "'artifacts.expose_as' requires 'artifacts.paths'", }) @@ -392,6 +414,7 @@ func checkArtifacts(name string, job model.Job) []Finding { 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", }) @@ -421,6 +444,7 @@ func checkCache(name string, job model.Job) []Finding { 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), }) @@ -428,6 +452,7 @@ func checkCache(name string, job model.Job) []Finding { 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), }) @@ -442,6 +467,7 @@ func checkRules(name string, job model.Job) []Finding { 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), }) @@ -462,6 +488,7 @@ func checkImage(name string, job model.Job) []Finding { if imgName == nil || imgName == "" { return []Finding{{ Severity: Error, + Rule: RuleInvalidImage, Job: name, Message: "'image' map form requires a 'name' key", }} @@ -489,6 +516,7 @@ func checkInherit(name string, job model.Job) []Finding { 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), }) diff --git a/internal/linter/linter.go b/internal/linter/linter.go index b51c08b..203dd84 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -16,6 +16,7 @@ const ( type Finding struct { Severity Severity + Rule string // stable rule ID, e.g. "GL003" — see rules.go Job string // empty for pipeline-level findings File string // source file where the finding originates Line int // line number in File (0 = unknown) @@ -31,11 +32,15 @@ func (f Finding) String() string { loc = fmt.Sprintf(" (%s)", f.File) } } - if f.Job != "" { - return fmt.Sprintf("[%s] job %q%s: %s", f.Severity, f.Job, loc, f.Message) + ruleStr := "" + if f.Rule != "" { + ruleStr = " " + f.Rule } - if loc != "" { - return fmt.Sprintf("[%s]%s: %s", f.Severity, loc, f.Message) + if f.Job != "" { + return fmt.Sprintf("[%s] job %q%s%s: %s", f.Severity, f.Job, loc, ruleStr, f.Message) + } + if loc != "" || ruleStr != "" { + return fmt.Sprintf("[%s]%s%s: %s", f.Severity, loc, ruleStr, f.Message) } return fmt.Sprintf("[%s] %s", f.Severity, f.Message) } @@ -56,6 +61,7 @@ func checkStages(p *model.Pipeline) []Finding { if len(p.Stages) == 0 { findings = append(findings, Finding{ Severity: Warning, + Rule: RuleNoStages, File: p.SourceFile, Message: "no stages defined; GitLab will use default stages (build, test, deploy)", }) @@ -72,6 +78,7 @@ func checkWorkflow(p *model.Pipeline) []Finding { if rule.When != "" && !validWorkflowRuleWhen[rule.When] { findings = append(findings, Finding{ Severity: Error, + Rule: RuleWorkflowWhen, File: p.SourceFile, Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When), }) @@ -113,6 +120,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { } findings = append(findings, Finding{ Severity: sev, + Rule: RuleMissingScript, Job: name, Message: "missing required field 'script' (or 'run')", }) @@ -124,6 +132,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { if job.Stage != "" && !strings.Contains(job.Stage, "$[[") && len(stageSet) > 0 && !stageSet[job.Stage] { findings = append(findings, Finding{ Severity: Error, + Rule: RuleUnknownStage, Job: name, Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage), }) @@ -133,6 +142,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { if job.Only != nil && len(job.Rules) > 0 { findings = append(findings, Finding{ Severity: Error, + Rule: RuleOnlyRulesConflict, Job: name, Message: "'only' and 'rules' cannot be used together", }) @@ -142,6 +152,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { if job.Except != nil && len(job.Rules) > 0 { findings = append(findings, Finding{ Severity: Error, + Rule: RuleExceptRulesConflict, Job: name, Message: "'except' and 'rules' cannot be used together", }) @@ -151,6 +162,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { if job.Only != nil || job.Except != nil { findings = append(findings, Finding{ Severity: Warning, + Rule: RuleDeprecatedOnly, Job: name, Message: "'only'/'except' are deprecated; prefer 'rules'", }) diff --git a/internal/linter/needs.go b/internal/linter/needs.go index 7b5b7b7..e6260f4 100644 --- a/internal/linter/needs.go +++ b/internal/linter/needs.go @@ -46,6 +46,7 @@ func checkNeeds(p *model.Pipeline) []Finding { } findings = append(findings, Finding{ Severity: sev, + Rule: RuleNeedsUnknown, Job: name, File: job.File, Line: job.Line, @@ -63,6 +64,7 @@ func checkNeeds(p *model.Pipeline) []Finding { if neededHasStage && neededStageIdx > jobStageIdx { findings = append(findings, Finding{ Severity: Error, + Rule: RuleNeedsStageOrder, Job: name, File: job.File, Line: job.Line, @@ -124,6 +126,7 @@ func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []F j := jobs[name] findings = append(findings, Finding{ Severity: Error, + Rule: RuleNeedsCycle, Job: name, File: j.File, Line: j.Line, diff --git a/internal/linter/rules.go b/internal/linter/rules.go new file mode 100644 index 0000000..5ab2129 --- /dev/null +++ b/internal/linter/rules.go @@ -0,0 +1,110 @@ +package linter + +// Rule ID constants. Each ID is stable across versions and uniquely identifies +// one lint check. Use these when filtering output, writing suppression rules, +// or referencing a check in documentation. +// +// Prefix GL = Glint / GitLab CI lint. +// Numbering is sequential by category; gaps may appear as rules are added. +const ( + // ── Pipeline-level ────────────────────────────────────────────────────── + + // GL001: no stages: block defined; GitLab falls back to default stages. + RuleNoStages = "GL001" + + // GL002: workflow.rules[n].when has an invalid value (only always/never allowed). + RuleWorkflowWhen = "GL002" + + // ── Job structure ──────────────────────────────────────────────────────── + + // GL003: job is missing a required script: (or run:) field. + RuleMissingScript = "GL003" + + // GL004: job references a stage not declared in stages:. + RuleUnknownStage = "GL004" + + // GL005: only: and rules: used together on the same job. + RuleOnlyRulesConflict = "GL005" + + // GL006: except: and rules: used together on the same job. + RuleExceptRulesConflict = "GL006" + + // GL007: only:/except: used (deprecated; prefer rules:). + RuleDeprecatedOnly = "GL007" + + // ── Keyword constraints ────────────────────────────────────────────────── + + // GL008: when: has an invalid value. + RuleInvalidWhen = "GL008" + + // GL009: when: delayed without start_in:. + RuleDelayedNoStartIn = "GL009" + + // GL010: start_in: set when when: is not delayed. + RuleStartInNoDelayed = "GL010" + + // GL011: parallel: value is invalid (integer out of range or map missing matrix:). + RuleInvalidParallel = "GL011" + + // GL012: retry: integer is out of range 0–2, or retry: is neither int nor map. + RuleInvalidRetry = "GL012" + + // GL013: retry.when: contains an unrecognised failure type. + RuleInvalidRetryWhen = "GL013" + + // GL014: allow_failure: is not a boolean or a map with exit_codes:. + RuleInvalidAllowFailure = "GL014" + + // GL015: interruptible: is not a boolean. + RuleInvalidInterruptible = "GL015" + + // GL016: trigger: job also defines script: (mutually exclusive). + RuleTriggerWithScript = "GL016" + + // GL017: trigger: map does not specify project: or include:. + RuleInvalidTrigger = "GL017" + + // GL018: coverage: is not a regex pattern wrapped in /. + RuleInvalidCoverage = "GL018" + + // GL019: release: is missing required tag_name:, or is not a map. + RuleInvalidRelease = "GL019" + + // GL020: environment: has an invalid url/action configuration. + RuleInvalidEnvironment = "GL020" + + // GL021: artifacts: has an invalid when/expose_as configuration. + RuleInvalidArtifacts = "GL021" + + // GL022: pages job artifacts.paths does not include public/. + RulePagesPublic = "GL022" + + // GL023: cache: has an invalid when/policy value. + RuleInvalidCache = "GL023" + + // GL024: rules[n].when has an invalid value. + RuleInvalidRulesWhen = "GL024" + + // GL025: image: map form is missing a name: key. + RuleInvalidImage = "GL025" + + // GL026: inherit.default or inherit.variables is not a boolean or list. + RuleInvalidInherit = "GL026" + + // ── Cross-job graph ────────────────────────────────────────────────────── + + // GL027: needs: references a job that does not exist in the pipeline. + RuleNeedsUnknown = "GL027" + + // GL028: needs: references a job in a later stage than the current job. + RuleNeedsStageOrder = "GL028" + + // GL029: circular dependency detected in the needs: graph. + RuleNeedsCycle = "GL029" + + // GL030: dependencies: references a job that does not exist. + RuleUnknownDependency = "GL030" + + // GL031: dependencies: references a job in the same or a later stage. + RuleDependencyStage = "GL031" +)