feat(linter): add GL032 variable reference validation in rules:if:
release / Build and publish release (push) Successful in 1m11s
release / Build and publish release (push) Successful in 1m11s
Warn when a $VAR or ${VAR} reference in a rules:if: expression is not
declared in pipeline variables:, the job's own variables:, or any
workflow:rules:variables: block. Predefined GitLab CI namespaces (CI_*,
GITLAB_*, FF_*, RUNNER_*, TRIGGER_*, CHAT_*) are always exempt.
Each undeclared variable is reported at most once per job. The finding
is a WARNING (not an error) because variables may also be set in GitLab
CI/CD project settings, which are invisible to glint at lint time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// predefinedVarPrefixes lists GitLab-maintained variable namespaces that are
|
||||
// always available without an explicit declaration in variables: blocks.
|
||||
var predefinedVarPrefixes = []string{
|
||||
"CI_", // most predefined CI variables (CI_COMMIT_BRANCH, CI_JOB_ID, …)
|
||||
"GITLAB_", // user/project metadata (GITLAB_USER_ID, GITLAB_FEATURES, …)
|
||||
"FF_", // GitLab feature flags
|
||||
"RUNNER_", // runner-level variables
|
||||
"TRIGGER_", // trigger token variables passed from upstream pipelines
|
||||
"CHAT_", // ChatOps variables
|
||||
}
|
||||
|
||||
func isPredefinedVar(name string) bool {
|
||||
for _, prefix := range predefinedVarPrefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractIfVars returns every variable name referenced in a rules:if: expression.
|
||||
// String literals are skipped so that dollar signs inside quoted values are
|
||||
// not mistaken for variable references.
|
||||
func extractIfVars(expr string) []string {
|
||||
var names []string
|
||||
i := 0
|
||||
for i < len(expr) {
|
||||
switch expr[i] {
|
||||
case '"', '\'':
|
||||
quote := expr[i]
|
||||
i++
|
||||
for i < len(expr) && expr[i] != quote {
|
||||
if expr[i] == '\\' {
|
||||
i++ // skip escaped character
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i < len(expr) {
|
||||
i++ // consume closing quote
|
||||
}
|
||||
case '$':
|
||||
i++ // consume '$'
|
||||
if i < len(expr) && expr[i] == '{' {
|
||||
i++ // consume '{'
|
||||
start := i
|
||||
for i < len(expr) && isVarNameByte(expr[i]) {
|
||||
i++
|
||||
}
|
||||
if i < len(expr) && expr[i] == '}' && i > start {
|
||||
names = append(names, expr[start:i])
|
||||
i++ // consume '}'
|
||||
}
|
||||
} else {
|
||||
start := i
|
||||
for i < len(expr) && isVarNameByte(expr[i]) {
|
||||
i++
|
||||
}
|
||||
if i > start {
|
||||
names = append(names, expr[start:i])
|
||||
}
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func isVarNameByte(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||
}
|
||||
|
||||
// checkVariableRefs warns when a rules:if: expression references a variable that
|
||||
// is not declared in pipeline variables:, the job's own variables:, or any
|
||||
// workflow:rules:variables: block. Predefined GitLab CI variables (CI_*, GITLAB_*,
|
||||
// …) are always exempt. Variables set in GitLab CI/CD project settings are
|
||||
// invisible to glint, so the finding is a WARNING rather than an error.
|
||||
func checkVariableRefs(p *model.Pipeline) []Finding {
|
||||
pipelineVars := make(map[string]bool, len(p.Variables))
|
||||
for k := range p.Variables {
|
||||
pipelineVars[k] = true
|
||||
}
|
||||
|
||||
// Union of all variables any workflow rule might inject into the context.
|
||||
workflowRuleVars := make(map[string]bool)
|
||||
if p.Workflow != nil {
|
||||
for _, rule := range p.Workflow.Rules {
|
||||
for k := range rule.Variables {
|
||||
workflowRuleVars[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
|
||||
// Check workflow rules:if: expressions.
|
||||
if p.Workflow != nil {
|
||||
seen := make(map[string]bool)
|
||||
for i, rule := range p.Workflow.Rules {
|
||||
if rule.If == "" {
|
||||
continue
|
||||
}
|
||||
for _, varName := range extractIfVars(rule.If) {
|
||||
if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] {
|
||||
continue
|
||||
}
|
||||
seen[varName] = true
|
||||
findings = append(findings, Finding{
|
||||
Severity: Warning,
|
||||
Rule: RuleUndeclaredVariable,
|
||||
File: p.SourceFile,
|
||||
Message: fmt.Sprintf("workflow.rules[%d].if: $%s is not declared in pipeline variables:", i, varName),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check each job's rules:if: expressions.
|
||||
for name, job := range p.Jobs {
|
||||
jobVars := make(map[string]bool, len(job.Variables))
|
||||
for k := range job.Variables {
|
||||
jobVars[k] = true
|
||||
}
|
||||
|
||||
// Deduplicate per (job, varName): report each undeclared variable once per job.
|
||||
seen := make(map[string]bool)
|
||||
for i, rule := range job.Rules {
|
||||
if rule.If == "" {
|
||||
continue
|
||||
}
|
||||
for _, varName := range extractIfVars(rule.If) {
|
||||
if isPredefinedVar(varName) || pipelineVars[varName] || jobVars[varName] || workflowRuleVars[varName] || seen[varName] {
|
||||
continue
|
||||
}
|
||||
seen[varName] = true
|
||||
findings = append(findings, Finding{
|
||||
Severity: Warning,
|
||||
Rule: RuleUndeclaredVariable,
|
||||
Job: name,
|
||||
File: job.File,
|
||||
Line: job.Line,
|
||||
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
Reference in New Issue
Block a user