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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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'",
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user