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>
This commit is contained in:
2026-06-11 22:56:24 +02:00
parent 6d0aefca5b
commit f48bf02152
7 changed files with 202 additions and 50 deletions
+2
View File
@@ -9,6 +9,8 @@ This project uses [Semantic Versioning](https://semver.org).
### Added ### 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 (GL001GL031) 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. - **`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: - **`rules:if:` expression evaluator improvements** — six correctness fixes to the GitLab CI expression parser:
+41 -46
View File
@@ -245,63 +245,58 @@ OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
## Lint rules ## 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 ### Pipeline-level
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | `workflow.rules[*].when` is not `always` or `never` | | GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
| WARNING | No `stages` defined (GitLab falls back to default stages) | | GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
### Job-level — structure ### Job-level — structure
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs | | GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| ERROR | Job references a `stage` not declared in `stages` | | GL004 | ERROR | Job references a `stage` not declared in `stages` |
| ERROR | `only` and `rules` used together on the same job | | GL005 | ERROR | `only` and `rules` used together on the same job |
| ERROR | `except` and `rules` used together on the same job | | GL006 | ERROR | `except` and `rules` used together on the same job |
| WARNING | `only`/`except` used (deprecated, prefer `rules`) | | GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
### Job-level — keyword constraints ### Job-level — keyword constraints
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` | | GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| ERROR | `when: delayed` without `start_in` | | GL009 | ERROR | `when: delayed` without `start_in` |
| ERROR | `start_in` set but `when` is not `delayed` | | GL010 | ERROR | `start_in` set but `when` is not `delayed` |
| ERROR | `parallel` integer not in range 2200 | | GL011 | ERROR | `parallel` integer not in range 2200, or map form missing `matrix` key |
| ERROR | `parallel` map form missing `matrix` key | | GL012 | ERROR | `retry` integer not in range 02, or `retry.max` out of range |
| ERROR | `retry` integer not in range 02 | | GL013 | ERROR | `retry.when` contains an unrecognised failure type |
| ERROR | `retry.max` not in range 02 | | GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| ERROR | `retry.when` contains an invalid failure type | | GL015 | ERROR | `interruptible` is not a boolean |
| ERROR | `allow_failure` is not a boolean or a map with `exit_codes` | | GL016 | ERROR | `trigger` job also has `script` |
| ERROR | `interruptible` is not a boolean | | GL017 | ERROR | `trigger` map missing `project` or `include` |
| ERROR | `trigger` job also has `script` | | GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` |
| ERROR | `trigger` map missing `project` or `include` | | GL019 | ERROR | `release` missing required `tag_name`, or is not a map |
| ERROR | `coverage` is not a regex pattern wrapped in `/` | | GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` |
| ERROR | `release` missing required `tag_name` | | GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` |
| ERROR | `environment.url` set without `environment.name` | | GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` |
| ERROR | `environment.action` is not one of `start`, `stop`, `prepare`, `verify`, `access` | | GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value |
| ERROR | `artifacts.when` is not `on_success`, `on_failure`, or `always` | | GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
| ERROR | `artifacts.expose_as` set without `artifacts.paths` | | GL025 | ERROR | `image` map form missing `name` key |
| ERROR | `cache.when` is not `on_success`, `on_failure`, or `always` | | GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
| 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` |
### Cross-job graph ### Cross-job graph
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | `needs:` references a job that does not exist | | GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) |
| ERROR | `needs:` references a job in a later stage | | GL028 | ERROR | `needs:` references a job in a later stage |
| ERROR | Circular dependency detected in `needs:` graph | | GL029 | ERROR | Circular dependency detected in `needs:` graph |
| ERROR | `dependencies:` references a job that does not exist | | GL030 | ERROR | `dependencies:` references a job that does not exist |
| ERROR | `dependencies:` references a job in the same or a later stage | | GL031 | 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 |
### Hidden jobs (templates) ### Hidden jobs (templates)
+2
View File
@@ -23,6 +23,7 @@ func checkDependencies(p *model.Pipeline) []Finding {
if !exists { if !exists {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleUnknownDependency,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
@@ -35,6 +36,7 @@ func checkDependencies(p *model.Pipeline) []Finding {
if depHasStage && depIdx >= jobStageIdx { if depHasStage && depIdx >= jobStageIdx {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleDependencyStage,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
+28
View File
@@ -105,6 +105,7 @@ func checkWhen(name string, job model.Job) []Finding {
if job.When != "" && !validJobWhen[job.When] { if job.When != "" && !validJobWhen[job.When] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidWhen,
Job: name, Job: name,
Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When), 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 == "" { if job.When == "delayed" && job.StartIn == "" {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleDelayedNoStartIn,
Job: name, Job: name,
Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')", 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 != "" { if job.When != "delayed" && job.StartIn != "" {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleStartInNoDelayed,
Job: name, Job: name,
Message: "'start_in' is only valid when 'when: delayed'", 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 { if v < 2 || v > 200 {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidParallel,
Job: name, Job: name,
Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v), 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 { if _, ok := v["matrix"]; !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidParallel,
Job: name, Job: name,
Message: "'parallel' map form must have a 'matrix' key", Message: "'parallel' map form must have a 'matrix' key",
}} }}
@@ -150,6 +155,7 @@ func checkParallel(name string, job model.Job) []Finding {
default: default:
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidParallel,
Job: name, Job: name,
Message: "'parallel' must be an integer (2200) or a map with 'matrix'", Message: "'parallel' must be an integer (2200) or a map with 'matrix'",
}} }}
@@ -166,6 +172,7 @@ func checkRetry(name string, job model.Job) []Finding {
if v < 0 || v > 2 { if v < 0 || v > 2 {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetry,
Job: name, Job: name,
Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v), 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) { if n, ok := maxVal.(int); ok && (n < 0 || n > 2) {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetry,
Job: name, Job: name,
Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n), 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: default:
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetry,
Job: name, Job: name,
Message: "'retry' must be an integer (02) or a map with 'max'/'when'", Message: "'retry' must be an integer (02) or a map with 'max'/'when'",
}} }}
@@ -201,6 +210,7 @@ func validateRetryWhen(name string, val any) []Finding {
if !validRetryWhen[s] { if !validRetryWhen[s] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetryWhen,
Job: name, Job: name,
Message: fmt.Sprintf("'retry.when' has invalid value %q", s), 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 { if _, ok := v["exit_codes"]; !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name, Job: name,
Message: "'allow_failure' map form must contain 'exit_codes'", Message: "'allow_failure' map form must contain 'exit_codes'",
}} }}
@@ -238,6 +249,7 @@ func checkAllowFailure(name string, job model.Job) []Finding {
_ = v _ = v
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name, Job: name,
Message: "'allow_failure' must be a boolean or a map with 'exit_codes'", 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 { if _, ok := job.Interruptible.(bool); !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidInterruptible,
Job: name, Job: name,
Message: "'interruptible' must be a boolean", Message: "'interruptible' must be a boolean",
}} }}
@@ -267,6 +280,7 @@ func checkTrigger(name string, job model.Job) []Finding {
if scriptNonEmpty(job.Script) { if scriptNonEmpty(job.Script) {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleTriggerWithScript,
Job: name, Job: name,
Message: "jobs with 'trigger' cannot use 'script'", Message: "jobs with 'trigger' cannot use 'script'",
}) })
@@ -277,6 +291,7 @@ func checkTrigger(name string, job model.Job) []Finding {
if !hasProject && !hasInclude { if !hasProject && !hasInclude {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidTrigger,
Job: name, Job: name,
Message: "'trigger' map must specify 'project' or 'include'", 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) { if !coveragePattern.MatchString(job.Coverage) {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidCoverage,
Job: name, Job: name,
Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage), 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 { if !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRelease,
Job: name, Job: name,
Message: "'release' must be a map", Message: "'release' must be a map",
}} }}
@@ -317,6 +334,7 @@ func checkRelease(name string, job model.Job) []Finding {
if !exists || tagName == "" || tagName == nil { if !exists || tagName == "" || tagName == nil {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRelease,
Job: name, Job: name,
Message: "'release' requires 'tag_name'", Message: "'release' requires 'tag_name'",
}} }}
@@ -339,6 +357,7 @@ func checkEnvironment(name string, job model.Job) []Finding {
if (envName == nil || envName == "") && hasURL { if (envName == nil || envName == "") && hasURL {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name, Job: name,
Message: "'environment.url' requires 'environment.name' to be set", 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] { if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name, Job: name,
Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action), 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] { if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name, Job: name,
Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w), 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 { if paths == nil {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name, Job: name,
Message: "'artifacts.expose_as' requires 'artifacts.paths'", Message: "'artifacts.expose_as' requires 'artifacts.paths'",
}) })
@@ -392,6 +414,7 @@ func checkArtifacts(name string, job model.Job) []Finding {
if !found { if !found {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Warning, Severity: Warning,
Rule: RulePagesPublic,
Job: name, Job: name,
Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy", 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] { if w, ok := m["when"].(string); ok && !validCacheWhen[w] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidCache,
Job: name, Job: name,
Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w), 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] { if p, ok := m["policy"].(string); ok && !validCachePolicy[p] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidCache,
Job: name, Job: name,
Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p), 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] { if rule.When != "" && !validRuleWhen[rule.When] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidRulesWhen,
Job: name, Job: name,
Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When), 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 == "" { if imgName == nil || imgName == "" {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidImage,
Job: name, Job: name,
Message: "'image' map form requires a 'name' key", Message: "'image' map form requires a 'name' key",
}} }}
@@ -489,6 +516,7 @@ func checkInherit(name string, job model.Job) []Finding {
default: default:
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidInherit,
Job: name, Job: name,
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key), Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
}) })
+16 -4
View File
@@ -16,6 +16,7 @@ const (
type Finding struct { type Finding struct {
Severity Severity Severity Severity
Rule string // stable rule ID, e.g. "GL003" — see rules.go
Job string // empty for pipeline-level findings Job string // empty for pipeline-level findings
File string // source file where the finding originates File string // source file where the finding originates
Line int // line number in File (0 = unknown) Line int // line number in File (0 = unknown)
@@ -31,11 +32,15 @@ func (f Finding) String() string {
loc = fmt.Sprintf(" (%s)", f.File) loc = fmt.Sprintf(" (%s)", f.File)
} }
} }
if f.Job != "" { ruleStr := ""
return fmt.Sprintf("[%s] job %q%s: %s", f.Severity, f.Job, loc, f.Message) if f.Rule != "" {
ruleStr = " " + f.Rule
} }
if loc != "" { if f.Job != "" {
return fmt.Sprintf("[%s]%s: %s", f.Severity, loc, f.Message) 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) return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
} }
@@ -56,6 +61,7 @@ func checkStages(p *model.Pipeline) []Finding {
if len(p.Stages) == 0 { if len(p.Stages) == 0 {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Warning, Severity: Warning,
Rule: RuleNoStages,
File: p.SourceFile, File: p.SourceFile,
Message: "no stages defined; GitLab will use default stages (build, test, deploy)", 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] { if rule.When != "" && !validWorkflowRuleWhen[rule.When] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleWorkflowWhen,
File: p.SourceFile, File: p.SourceFile,
Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When), 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{ findings = append(findings, Finding{
Severity: sev, Severity: sev,
Rule: RuleMissingScript,
Job: name, Job: name,
Message: "missing required field 'script' (or 'run')", 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] { if job.Stage != "" && !strings.Contains(job.Stage, "$[[") && len(stageSet) > 0 && !stageSet[job.Stage] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleUnknownStage,
Job: name, Job: name,
Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage), 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 { if job.Only != nil && len(job.Rules) > 0 {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleOnlyRulesConflict,
Job: name, Job: name,
Message: "'only' and 'rules' cannot be used together", 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 { if job.Except != nil && len(job.Rules) > 0 {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleExceptRulesConflict,
Job: name, Job: name,
Message: "'except' and 'rules' cannot be used together", 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 { if job.Only != nil || job.Except != nil {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Warning, Severity: Warning,
Rule: RuleDeprecatedOnly,
Job: name, Job: name,
Message: "'only'/'except' are deprecated; prefer 'rules'", Message: "'only'/'except' are deprecated; prefer 'rules'",
}) })
+3
View File
@@ -46,6 +46,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
} }
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: sev, Severity: sev,
Rule: RuleNeedsUnknown,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
@@ -63,6 +64,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
if neededHasStage && neededStageIdx > jobStageIdx { if neededHasStage && neededStageIdx > jobStageIdx {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleNeedsStageOrder,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
@@ -124,6 +126,7 @@ func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []F
j := jobs[name] j := jobs[name]
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleNeedsCycle,
Job: name, Job: name,
File: j.File, File: j.File,
Line: j.Line, Line: j.Line,
+110
View File
@@ -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 02, 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"
)