Files
glint/internal/linter/inherit_completeness.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

115 lines
2.7 KiB
Go

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,
Column: job.Column,
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,
Column: job.Column,
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"
}