Files
glint/internal/linter/variables.go
T
k3nny 8c3605ed52 feat(cli): colorized, columnized text output with line:col locations
Replace the per-line fmt.Println loop with writeTextFindings() in
cmd/glint/output.go. The new renderer:

- Aligns all findings into four space-separated columns: location,
  rule ID, severity, message — widths computed from the full finding
  set so all lines are flush
- Colors severity words when stdout is a TTY and NO_COLOR is not set:
  red+bold for "error", orange+bold for "warning"; location dimmed;
  rule ID bold
- Formats location as file:line:col when column is known, file:line
  otherwise, falling back to just file

To populate column numbers, add Column int to model.Job (set from
keyNode.Column in the YAML parser) and Finding.Column (set during
the checkJob source-location attachment pass and in the ten cross-job
check sites that explicitly set Line: job.Line).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:13:52 +02:00

160 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,
Column: job.Column,
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
})
}
}
}
return findings
}