18c8fc82c9
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>
159 lines
4.3 KiB
Go
159 lines
4.3 KiB
Go
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
|
|
}
|