feat(linter): glint explain, GL042 rules:if: reachability, GL043 inherit completeness

- `glint explain <RULE>`: new subcommand printing rule description,
  rationale, bad-YAML example and fix for every GL001–GL043 rule.
  `glint explain` (no arg) lists all rules with ID, severity, title.
  Rule IDs are case-insensitive.

- GL042 (rules:if: evaluated reachability): warns when every rules:if:
  condition evaluates to false given the values of variables declared in
  the pipeline YAML, making the job statically unreachable. Conservative:
  only fires when all referenced variables are declared in YAML; predefined
  CI_* / GITLAB_* variables are skipped to avoid false positives.

- GL043 (inherit: completeness): warns when inherit: default: is declared
  but there is no default: block in the pipeline (dead declaration), or
  when the list form names fields not set in the default: block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 11:02:23 +02:00
parent 02d8e63a98
commit 4ce7f86d4d
17 changed files with 1528 additions and 50 deletions
+819
View File
@@ -0,0 +1,819 @@
package linter
// RuleEntry holds the human-readable documentation for a single lint rule,
// shown by 'glint explain <RULE>'.
type RuleEntry struct {
Title string // short title (one line)
Severity Severity // Error or Warning
Description string // what triggers the rule and why it matters
Example string // YAML snippet that triggers the rule
Fix string // corrected YAML
}
// RuleCatalog maps stable rule IDs to their documentation entries.
var RuleCatalog = map[string]RuleEntry{
RuleNoStages: {
Title: "no stages defined",
Severity: Warning,
Description: "The pipeline has no 'stages:' block. GitLab falls back to the " +
"built-in default stages (build, test, deploy), which may not match your " +
"intended job ordering.",
Example: `# No stages: block
build-job:
script: make build
test-job:
script: make test`,
Fix: `stages:
- build
- test
build-job:
stage: build
script: make build
test-job:
stage: test
script: make test`,
},
RuleWorkflowWhen: {
Title: "workflow.rules.when invalid value",
Severity: Error,
Description: "'workflow.rules[n].when' only accepts 'always' or 'never'. Any " +
"other value (such as 'on_success' or 'manual') is invalid and will cause " +
"the pipeline to fail to create.",
Example: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success # invalid here`,
Fix: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: always`,
},
RuleMissingScript: {
Title: "missing required 'script' field",
Severity: Error,
Description: "Every non-trigger, non-pages job must define 'script:' (or 'run:' " +
"for CI Steps). A job with no script will fail immediately when GitLab tries " +
"to run it.",
Example: `my-job:
stage: test
image: python:3.12
# Missing script:`,
Fix: `my-job:
stage: test
image: python:3.12
script: pytest`,
},
RuleUnknownStage: {
Title: "job references undeclared stage",
Severity: Error,
Description: "The job's 'stage:' value does not match any name in the pipeline's " +
"'stages:' list. GitLab will reject the pipeline configuration.",
Example: `stages:
- build
- test
deploy-job:
stage: production # not in stages:
script: ./deploy.sh`,
Fix: `stages:
- build
- test
- production
deploy-job:
stage: production
script: ./deploy.sh`,
},
RuleOnlyRulesConflict: {
Title: "'only:' and 'rules:' used together",
Severity: Error,
Description: "'only:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
only:
- main
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `# Remove only: and use rules: exclusively
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: echo ok`,
},
RuleExceptRulesConflict: {
Title: "'except:' and 'rules:' used together",
Severity: Error,
Description: "'except:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
except:
- tags
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH
script: echo ok`,
},
RuleDeprecatedOnly: {
Title: "'only:' / 'except:' deprecated",
Severity: Warning,
Description: "'only:' and 'except:' are deprecated in favour of 'rules:'. " +
"GitLab may remove support for these keywords in a future major version. " +
"'rules:' is more expressive and supports variable references, merge-request " +
"conditions, and fine-grained 'when:' control.",
Example: `my-job:
only:
- main
script: ./deploy.sh`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: ./deploy.sh`,
},
RuleInvalidWhen: {
Title: "'when:' has invalid value",
Severity: Error,
Description: "Job-level 'when:' accepts: on_success, on_failure, always, " +
"manual, delayed, never. Any other value is rejected by GitLab.",
Example: `my-job:
when: sometimes # invalid
script: echo hi`,
Fix: `my-job:
when: on_success
script: echo hi`,
},
RuleDelayedNoStartIn: {
Title: "'when: delayed' without 'start_in:'",
Severity: Error,
Description: "'when: delayed' requires a 'start_in:' duration (e.g. " +
"'30 minutes', '1 hour'). Without it GitLab rejects the job configuration.",
Example: `my-job:
when: delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleStartInNoDelayed: {
Title: "'start_in:' without 'when: delayed'",
Severity: Error,
Description: "'start_in:' is only meaningful when 'when: delayed'. Setting it " +
"alongside any other 'when:' value is a configuration error.",
Example: `my-job:
when: on_success
start_in: 30 minutes # only valid with delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleInvalidParallel: {
Title: "'parallel:' value invalid",
Severity: Error,
Description: "'parallel:' must be an integer between 2 and 200 (inclusive), " +
"or a map with a 'matrix:' key for matrix jobs. Values outside this range " +
"or the wrong type are rejected by GitLab.",
Example: `my-job:
parallel: 1 # must be 2200
script: echo hi`,
Fix: `my-job:
parallel: 5
script: echo hi`,
},
RuleInvalidRetry: {
Title: "'retry:' value invalid",
Severity: Error,
Description: "'retry:' accepts an integer 02 or a map with optional 'max:' " +
"(02) and 'when:' keys. Values outside this range are rejected.",
Example: `my-job:
retry: 5 # max is 2
script: echo hi`,
Fix: `my-job:
retry: 2
script: echo hi`,
},
RuleInvalidRetryWhen: {
Title: "'retry.when:' has invalid failure type",
Severity: Error,
Description: "'retry.when:' must be a recognised GitLab CI failure type " +
"(e.g. script_failure, runner_system_failure) or 'always'. Unknown " +
"values are rejected.",
Example: `my-job:
retry:
max: 2
when: disk_full # not a valid failure type
script: echo hi`,
Fix: `my-job:
retry:
max: 2
when: runner_system_failure
script: echo hi`,
},
RuleInvalidAllowFailure: {
Title: "'allow_failure:' invalid value",
Severity: Error,
Description: "'allow_failure:' must be a boolean (true/false) or a map with " +
"an 'exit_codes:' key. Other forms are rejected by GitLab.",
Example: `my-job:
allow_failure: maybe # must be true/false or map
script: echo hi`,
Fix: `my-job:
allow_failure: true
script: echo hi`,
},
RuleInvalidInterruptible: {
Title: "'interruptible:' is not a boolean",
Severity: Error,
Description: "'interruptible:' must be a boolean (true or false). Other types " +
"are rejected.",
Example: `my-job:
interruptible: yes # must be a YAML boolean true/false
script: echo hi`,
Fix: `my-job:
interruptible: true
script: echo hi`,
},
RuleTriggerWithScript: {
Title: "trigger job also defines 'script:'",
Severity: Error,
Description: "Jobs with a 'trigger:' key (downstream pipeline triggers) cannot " +
"use 'script:'. These are mutually exclusive — a trigger job has no runner " +
"environment to execute scripts in.",
Example: `trigger-job:
trigger:
project: mygroup/myproject
script: echo this will fail`,
Fix: `trigger-job:
trigger:
project: mygroup/myproject`,
},
RuleInvalidTrigger: {
Title: "trigger: map missing 'project:' or 'include:'",
Severity: Error,
Description: "When 'trigger:' is a map it must specify either 'project:' " +
"(downstream project trigger) or 'include:' (dynamic child pipeline). " +
"Without one of these keys GitLab cannot determine what to trigger.",
Example: `trigger-job:
trigger:
strategy: depend # missing project: or include:`,
Fix: `trigger-job:
trigger:
project: mygroup/downstream
strategy: depend`,
},
RuleInvalidCoverage: {
Title: "'coverage:' is not a regex pattern",
Severity: Error,
Description: "'coverage:' must be a regex pattern wrapped in forward slashes " +
"(e.g. '/Coverage: \\d+\\.?\\d*%/'). GitLab uses this pattern to extract " +
"the coverage percentage from job output.",
Example: `my-job:
coverage: "\\d+%" # missing surrounding /…/
script: pytest --cov`,
Fix: `my-job:
coverage: '/Coverage: \d+\.?\d*%/'
script: pytest --cov`,
},
RuleInvalidRelease: {
Title: "'release:' missing required 'tag_name:'",
Severity: Error,
Description: "'release:' must be a map and must include a 'tag_name:' key. " +
"Without it GitLab cannot create the release.",
Example: `release-job:
script: echo releasing
release:
name: My Release # missing tag_name:`,
Fix: `release-job:
script: echo releasing
release:
tag_name: $CI_COMMIT_TAG
name: My Release`,
},
RuleInvalidEnvironment: {
Title: "'environment:' invalid url or action",
Severity: Error,
Description: "'environment.url' requires 'environment.name' to be set. " +
"'environment.action' must be one of: start, stop, prepare, verify, access.",
Example: `my-job:
script: ./deploy.sh
environment:
url: https://prod.example.com # name: is required`,
Fix: `my-job:
script: ./deploy.sh
environment:
name: production
url: https://prod.example.com`,
},
RuleInvalidArtifacts: {
Title: "'artifacts:' invalid configuration",
Severity: Error,
Description: "'artifacts.when' must be on_success, on_failure, or always. " +
"'artifacts.expose_as' requires 'artifacts.paths' to be set.",
Example: `my-job:
script: make
artifacts:
when: sometimes # invalid value`,
Fix: `my-job:
script: make
artifacts:
when: on_failure
paths:
- build/logs/`,
},
RulePagesPublic: {
Title: "pages job missing 'public' in artifacts.paths",
Severity: Warning,
Description: "The 'pages' job must include 'public' (or a path starting with " +
"'public/') in 'artifacts.paths' for GitLab Pages to deploy the site.",
Example: `pages:
script: hugo
artifacts:
paths:
- dist/ # should include public/`,
Fix: `pages:
script: hugo --destination public
artifacts:
paths:
- public/`,
},
RuleInvalidCache: {
Title: "'cache:' invalid when or policy",
Severity: Error,
Description: "'cache.when' must be on_success, on_failure, or always. " +
"'cache.policy' must be pull, push, or pull-push.",
Example: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: read-only # invalid; use pull`,
Fix: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: pull`,
},
RuleInvalidRulesWhen: {
Title: "'rules[n].when:' has invalid value",
Severity: Error,
Description: "Inside a 'rules:' block, 'when:' must be one of: on_success, " +
"on_failure, always, manual, delayed, never. Other values are rejected.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: conditional # not valid here
script: echo hi`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success
script: echo hi`,
},
RuleInvalidImage: {
Title: "'image:' map form missing 'name:'",
Severity: Error,
Description: "When 'image:' is a map, it must include a 'name:' key specifying " +
"the Docker image. Without it GitLab cannot resolve which image to pull.",
Example: `my-job:
image:
entrypoint: ["/bin/sh"] # missing name:
script: echo hi`,
Fix: `my-job:
image:
name: alpine:3.19
entrypoint: ["/bin/sh"]
script: echo hi`,
},
RuleInvalidInherit: {
Title: "'inherit.default' or 'inherit.variables' invalid type",
Severity: Error,
Description: "'inherit.default' and 'inherit.variables' must each be a boolean " +
"(true/false) or a list of field/variable names. Other types are rejected.",
Example: `my-job:
inherit:
default: "no" # must be true/false or a list
script: echo hi`,
Fix: `my-job:
inherit:
default: false
script: echo hi`,
},
RuleNeedsUnknown: {
Title: "'needs:' references unknown job",
Severity: Error,
Description: "A job in the 'needs:' list does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `build:
stage: build
script: make
test:
stage: test
needs: [build, lint] # lint does not exist
script: make test`,
Fix: `lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
needs: [build, lint]
script: make test`,
},
RuleNeedsStageOrder: {
Title: "'needs:' job is in a later stage",
Severity: Error,
Description: "A job listed in 'needs:' is in a later stage than the current " +
"job. GitLab does not allow needs: to reference future stages.",
Example: `stages: [build, test, deploy]
test:
stage: test
needs: [deploy-job] # deploy-job is in a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
test:
stage: test
needs: [build-job]
script: make test
build-job:
stage: build
script: make`,
},
RuleNeedsCycle: {
Title: "circular dependency in 'needs:' graph",
Severity: Error,
Description: "Two or more jobs in the 'needs:' graph form a cycle — A needs B " +
"and B needs A (directly or transitively). GitLab will reject the pipeline.",
Example: `job-a:
stage: test
needs: [job-b]
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
Fix: `job-a:
stage: test
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
},
RuleUnknownDependency: {
Title: "'dependencies:' references unknown job",
Severity: Error,
Description: "A job listed in 'dependencies:' does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `test:
stage: test
dependencies: [build, missing-job]
script: make test`,
Fix: `build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleDependencyStage: {
Title: "'dependencies:' job in same or later stage",
Severity: Error,
Description: "'dependencies:' can only reference jobs in earlier stages. " +
"Referencing a job in the same or a later stage is not allowed because " +
"artifacts would not yet be available.",
Example: `stages: [test, deploy]
test:
stage: test
dependencies: [deploy-job] # deploy is a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleUndeclaredVariable: {
Title: "'rules:if:' references undeclared variable",
Severity: Warning,
Description: "A 'rules:if:' expression references a variable that is not " +
"declared in any 'variables:' block in the pipeline YAML. This may be a " +
"typo, or the variable may be set in GitLab project settings (invisible to " +
"glint). Predefined CI_* and GITLAB_* variables are always exempt.",
Example: `variables:
APP_ENV: staging
my-job:
rules:
- if: $DEPLOY_ENV == "prod" # DEPLOY_ENV not declared
script: ./deploy.sh`,
Fix: `variables:
APP_ENV: staging
DEPLOY_ENV: staging # declare it, or set it in GitLab project settings
my-job:
rules:
- if: $DEPLOY_ENV == "prod"
script: ./deploy.sh`,
},
RuleDeadRules: {
Title: "rules: block has all 'when: never' — job permanently excluded",
Severity: Warning,
Description: "Every rule in the job's 'rules:' block has an explicit " +
"'when: never'. No matter which conditions match, the job will never " +
"be included in any pipeline run.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: never
- when: never
script: echo unreachable`,
Fix: `# Option 1: remove the job entirely
# Option 2: give at least one rule a non-never when:
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
- when: never
script: echo deploy`,
},
RuleInvalidService: {
Title: "'services:' map form missing 'name:' or invalid 'alias:'",
Severity: Error,
Description: "When a service entry is a map it must include a 'name:' key. " +
"'alias:' (if set) must be a valid DNS label: alphanumeric characters, " +
"hyphens, and dots only; must start and end with alphanumeric.",
Example: `my-job:
script: pytest
services:
- alias: my_db # underscore is not valid in a DNS label`,
Fix: `my-job:
script: pytest
services:
- name: postgres:15
alias: my-db`,
},
RuleAbsoluteGlobPath: {
Title: "'rules:changes' or 'rules:exists' uses absolute path",
Severity: Warning,
Description: "Paths in 'rules:changes' and 'rules:exists' are always relative " +
"to the repository root. An absolute path starting with '/' will never " +
"match any file and will silently disable the rule.",
Example: `my-job:
rules:
- changes:
- /src/**/*.go # absolute — will never match
script: make`,
Fix: `my-job:
rules:
- changes:
- src/**/*.go
script: make`,
},
RuleInvalidTimeout: {
Title: "'timeout:' is not a valid duration string",
Severity: Error,
Description: "'timeout:' must be a GitLab CI duration string composed of " +
"recognised time units: weeks (w), days (d), hours (h), minutes (m/min), " +
"seconds (s). Examples: '1h 30m', '90 minutes', '2 hours'.",
Example: `my-job:
timeout: "1:30:00" # colon-separated format not supported
script: long-running-task.sh`,
Fix: `my-job:
timeout: 1h 30m
script: long-running-task.sh`,
},
RuleInvalidIDToken: {
Title: "'id_tokens:' entry missing required 'aud:' key",
Severity: Error,
Description: "Each entry under 'id_tokens:' must be a map with an 'aud:' " +
"(audience) key. Without 'aud:' GitLab cannot generate the ID token " +
"and the job will fail.",
Example: `my-job:
id_tokens:
MY_TOKEN:
# missing aud:
script: vault-login.sh`,
Fix: `my-job:
id_tokens:
MY_TOKEN:
aud: https://vault.example.com
script: vault-login.sh`,
},
RuleInvalidSecret: {
Title: "'secrets:' entry missing a provider key",
Severity: Error,
Description: "Each entry under 'secrets:' must specify a provider: one of " +
"'vault:', 'gcp_secret_manager:', or 'azure_key_vault:'. Without a " +
"provider GitLab cannot retrieve the secret.",
Example: `my-job:
secrets:
DB_PASSWORD:
path: secret/db/password # no provider key
script: run.sh`,
Fix: `my-job:
secrets:
DB_PASSWORD:
vault:
engine:
name: kv-v2
path: secret
path: db/password
field: password
script: run.sh`,
},
RulePagesPublish: {
Title: "'pages:' keyword publish directory missing from 'artifacts.paths'",
Severity: Warning,
Description: "A job using the 'pages:' keyword must include its publish " +
"directory in 'artifacts.paths'. Without this, GitLab Pages will not " +
"deploy the site.",
Example: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- dist/ # missing public/`,
Fix: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- public/`,
},
RuleDuplicateStage: {
Title: "duplicate stage name in 'stages:'",
Severity: Warning,
Description: "A stage name appears more than once in the 'stages:' list. " +
"GitLab silently merges duplicate stage entries, which can make the " +
"pipeline order confusing.",
Example: `stages:
- build
- test
- test # duplicate`,
Fix: `stages:
- build
- test`,
},
RuleInvalidCacheKeyFiles: {
Title: "'cache.key.files' contains a glob pattern",
Severity: Warning,
Description: "'cache.key.files' must be a list of exact file paths. Glob " +
"patterns (*, ?, [...]) are not expanded — the literal glob string is " +
"used as the cache key, which probably doesn't match your intent.",
Example: `my-job:
script: npm ci
cache:
key:
files:
- package*.json # glob — use exact path`,
Fix: `my-job:
script: npm ci
cache:
key:
files:
- package.json
- package-lock.json`,
},
RuleStaticDeadRules: {
Title: "rules:if: block statically never activates",
Severity: Warning,
Description: "Every 'rules:if:' condition in this job evaluates to false " +
"using the variable values declared in the pipeline YAML. The job will " +
"never be included in any pipeline run. This check only fires when ALL " +
"referenced variables are declared in the YAML (predefined CI_* variables " +
"are not evaluated to avoid false positives).",
Example: `variables:
ENABLE_DEPLOY: "false"
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"' # always false: ENABLE_DEPLOY is "false"
script: ./deploy.sh`,
Fix: `variables:
ENABLE_DEPLOY: "true" # fix the variable value
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
script: ./deploy.sh
# Or add an unconditional fallback:
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
- when: manual # allow manual trigger as a fallback
script: ./deploy.sh`,
},
RuleInheritNoDefault: {
Title: "'inherit: default:' declared but no 'default:' block",
Severity: Warning,
Description: "A job declares 'inherit: default:' but the pipeline has no " +
"'default:' block, so the declaration has no effect. This can also fire " +
"when 'inherit: default: [list]' names fields that are not set in the " +
"'default:' block.",
Example: `# No default: block exists
my-job:
inherit:
default: false # no-op: nothing to opt out of
script: echo hi`,
Fix: `# Either add a default: block...
default:
before_script:
- source venv/bin/activate
my-job:
inherit:
default: false
script: echo hi
# ...or remove the pointless inherit: declaration
my-job:
script: echo hi`,
},
}
+104
View File
@@ -0,0 +1,104 @@
package linter
import (
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// checkRulesIfReachability (GL042): warn when every rule in a job's rules: block
// can be statically proven to never activate given the values of variables
// declared in the pipeline YAML. Only fires when ALL variables referenced by the
// rules:if: expressions are declared in the pipeline or job variables: blocks —
// predefined CI_* / GITLAB_* variables or variables not declared in YAML are
// treated as unknown (could have any runtime value) so the check is skipped
// conservatively to avoid false positives.
func checkRulesIfReachability(p *model.Pipeline) []Finding {
if len(p.Jobs) == 0 {
return nil
}
// Collect scalar string values from pipeline-level variables.
pipelineVars := make(map[string]string, len(p.Variables))
for k, v := range p.Variables {
if s, ok := cicontext.ScalarString(v); ok {
pipelineVars[k] = s
}
}
var findings []Finding
for name, job := range p.Jobs {
if len(job.Rules) == 0 {
continue
}
// Merge pipeline vars with job-level variable overrides.
jobVars := make(map[string]string, len(pipelineVars)+len(job.Variables))
for k, v := range pipelineVars {
jobVars[k] = v
}
for k, v := range job.Variables {
if s, ok := cicontext.ScalarString(v); ok {
jobVars[k] = s
}
}
if f := evalRulesReachability(name, job, jobVars); f != nil {
findings = append(findings, *f)
}
}
return findings
}
// evalRulesReachability returns a GL042 finding when the job's rules: block
// can never activate given the provided variable map, or nil if the job
// might activate (or the check cannot be applied conservatively).
func evalRulesReachability(name string, job model.Job, jobVars map[string]string) *Finding {
lookup := func(k string) string { return jobVars[k] }
for _, rule := range job.Rules {
// Explicit when: never — this rule is provably dead; continue.
if rule.When == "never" {
continue
}
// No if: condition → this rule always matches → job CAN activate.
if rule.If == "" {
return nil
}
// Check whether all variables referenced in the if: expression are
// declared in the pipeline YAML. If any are not declared (predefined
// CI vars, project settings, etc.), we cannot evaluate the expression
// and must conservatively assume the job might activate.
refs := extractIfVars(rule.If)
allDeclared := true
for _, ref := range refs {
if _, ok := jobVars[ref]; !ok {
// Variable not in YAML (predefined or unknown) → cannot evaluate.
allDeclared = false
break
}
}
if !allDeclared {
return nil // conservative: job might activate
}
// All referenced variables are declared; evaluate the expression.
// Use strict mode (unparse → false) to avoid matching unparseable expressions.
if cicontext.EvalIfStrict(rule.If, lookup) {
// This rule's condition evaluates to true → job CAN activate.
return nil
}
// Expression evaluated to false → this rule cannot activate; continue.
}
// No rule could activate the job.
return &Finding{
Severity: Warning,
Rule: RuleStaticDeadRules,
Job: name,
File: job.File,
Line: job.Line,
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
}
}
+152
View File
@@ -0,0 +1,152 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckRulesIfReachability(t *testing.T) {
makeJob := func(vars map[string]any, rules []model.Rule) model.Job {
return model.Job{Variables: vars, Rules: rules}
}
cases := []struct {
name string
pipelineVars map[string]any
jobs map[string]model.Job
wantHit []string // job names that should produce GL042
wantMiss []string // job names that should NOT produce GL042
}{
{
name: "declared var always false — GL042 fires",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: []string{"deploy"},
wantMiss: nil,
},
{
name: "declared var matches — no finding",
pipelineVars: map[string]any{"DEPLOY": "true"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"deploy"},
},
{
name: "predefined CI_ var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"branch-job": makeJob(nil, []model.Rule{
{If: `$CI_COMMIT_BRANCH == "never-exists"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"branch-job"},
},
{
name: "undeclared var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$UNKNOWN_VAR == "x"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "unconditional rule — job can always activate",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "never"},
{When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "all rules when:never — GL042 fires (not GL033 territory overlap)",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
{When: "never"},
}),
},
// Rule 1 evaluates to false; Rule 2 is explicit never → all dead.
wantHit: []string{"my-job"},
wantMiss: nil,
},
{
name: "job-level var overrides pipeline var",
pipelineVars: map[string]any{"FLAG": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(map[string]any{"FLAG": "true"}, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// Job-level FLAG="true" makes the if: evaluate to true → no finding.
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "no rules — nothing to check",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, nil),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "boolean variable evaluates correctly",
pipelineVars: map[string]any{"FLAG": false}, // bool false
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// ScalarString(false) → "false"; "false" == "true" → false → GL042 fires.
wantHit: []string{"my-job"},
wantMiss: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &model.Pipeline{
Variables: tc.pipelineVars,
Jobs: tc.jobs,
}
findings := checkRulesIfReachability(p)
hitSet := make(map[string]bool)
for _, f := range findings {
if f.Rule == RuleStaticDeadRules {
hitSet[f.Job] = true
}
}
for _, want := range tc.wantHit {
if !hitSet[want] {
t.Errorf("expected GL042 for job %q but it was not reported; findings: %v", want, findings)
}
}
for _, notWant := range tc.wantMiss {
if hitSet[notWant] {
t.Errorf("unexpected GL042 for job %q", notWant)
}
}
})
}
}
+112
View File
@@ -0,0 +1,112 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// checkInheritCompleteness (GL043): warn when a job's 'inherit: default:'
// declaration is dead — either because there is no 'default:' block in the
// pipeline, or because the list form references fields that are not defined
// in the 'default:' block.
func checkInheritCompleteness(p *model.Pipeline) []Finding {
var findings []Finding
for name, job := range p.Jobs {
findings = append(findings, checkJobInheritCompleteness(p, name, job)...)
}
return findings
}
func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job) []Finding {
if job.Inherit == nil {
return nil
}
m, ok := job.Inherit.(map[string]any)
if !ok {
return nil
}
defaultVal, hasDefault := m["default"]
if !hasDefault {
return nil
}
// Case 1: no default: block at all — the entire declaration is a no-op.
if p.Default == nil {
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect",
}}
}
// Case 2: list form — check for field names not set in default:.
list, ok := defaultVal.([]any)
if !ok {
// bool form (true/false) with a non-nil default: block is always valid.
return nil
}
var dead []string
for _, item := range list {
field, ok := item.(string)
if !ok {
continue
}
if !defaultBlockHasField(p.Default, field) {
dead = append(dead, field)
}
}
if len(dead) == 0 {
return nil
}
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
strings.Join(dead, ", "),
pluralIs(len(dead)),
"these entries have no effect",
),
}}
}
// defaultBlockHasField reports whether the given field name has a non-zero value
// in the default: block. Unknown field names return true (conservative).
func defaultBlockHasField(d *model.DefaultConfig, field string) bool {
switch field {
case "image":
return d.Image != nil
case "before_script":
return d.BeforeScript != nil
case "after_script":
return d.AfterScript != nil
case "cache":
return d.Cache != nil
case "artifacts":
return d.Artifacts != nil
case "retry":
return d.Retry != nil
case "timeout":
return d.Timeout != ""
case "tags":
return len(d.Tags) > 0
}
// Unknown field name — conservatively assume it might be defined.
return true
}
func pluralIs(n int) string {
if n == 1 {
return "this field is"
}
return "these fields are"
}
@@ -0,0 +1,94 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckInheritCompleteness(t *testing.T) {
cases := []struct {
name string
default_ *model.DefaultConfig
inherit any // job.Inherit value
wantHit bool
}{
{
name: "no inherit — no finding",
inherit: nil,
wantHit: false,
},
{
name: "inherit: default: false, no default block — GL043",
inherit: map[string]any{"default": false},
wantHit: true,
},
{
name: "inherit: default: true, no default block — GL043",
inherit: map[string]any{"default": true},
wantHit: true,
},
{
name: "inherit: default: [image], no default block — GL043",
inherit: map[string]any{"default": []any{"image"}},
wantHit: true,
},
{
name: "inherit: default: false, default block exists — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": false},
wantHit: false,
},
{
name: "inherit: default: [image], image in default — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image"}},
wantHit: false,
},
{
name: "inherit: default: [before_script], before_script NOT in default — GL043",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"before_script"}},
wantHit: true,
},
{
name: "inherit: default: [image, before_script], only image in default — GL043 for before_script",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image", "before_script"}},
wantHit: true,
},
{
name: "inherit: variables only — no default check",
default_: nil,
inherit: map[string]any{"variables": false},
wantHit: false,
},
{
name: "inherit: unknown field in list — conservative, no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"nonexistent_field"}},
wantHit: false, // unknown fields return true from defaultBlockHasField
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Inherit: tc.inherit}
p := &model.Pipeline{
Default: tc.default_,
Jobs: map[string]model.Job{"test-job": job},
}
findings := checkInheritCompleteness(p)
hit := false
for _, f := range findings {
if f.Rule == RuleInheritNoDefault {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("GL043 hit=%v, want=%v; findings: %v", hit, tc.wantHit, findings)
}
})
}
}
+2
View File
@@ -62,6 +62,8 @@ func Lint(p *model.Pipeline) []Finding {
findings = append(findings, checkNeeds(p)...)
findings = append(findings, checkDependencies(p)...)
findings = append(findings, checkVariableRefs(p)...)
findings = append(findings, checkRulesIfReachability(p)...)
findings = append(findings, checkInheritCompleteness(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
+11
View File
@@ -143,4 +143,15 @@ const (
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
RuleInvalidCacheKeyFiles = "GL041"
// GL042: every rules:if: condition in a job's rules: block evaluates to false
// given the values of variables declared in the pipeline YAML, so the job can
// never be active. Only fires when all referenced variables are declared
// (predefined CI_* / GITLAB_* vars are not evaluated to avoid false positives).
RuleStaticDeadRules = "GL042"
// GL043: a job declares 'inherit: default:' (true, false, or list) but the
// pipeline has no 'default:' block, making the declaration a no-op. Also fires
// when 'inherit: default: [list]' names fields not set in the default: block.
RuleInheritNoDefault = "GL043"
)