feat(cli): .glint.yml config and inline suppression comments
Adds project-level configuration and per-job suppression directives:
.glint.yml (searched from pipeline dir up to the git root):
- ignore: [GL007, GL032] — suppress rules globally for the project
- severity: {GL004: warning} — override rule severity (error/warning/ignore)
- stages: [quality] — extra stages beyond the pipeline's stages: block
- token: / url: / cache_dir: — defaults for flags; lower priority than
CLI flags and environment variables
Inline suppression (# glint: ignore):
- Place "# glint: ignore GL007" immediately before a job definition to
suppress that rule for the specific job only
- Multiple rules: "# glint: ignore GL007, GL032" (comma or space separated)
- Wildcard: "# glint: ignore all" suppresses every finding for the job
- Suppressions are scoped to the annotated job; pipeline-level findings
are unaffected
- Parsed from yaml.Node head/line comments in the first parse pass;
stored in Pipeline.Suppressions (root file only, not includes)
New packages: internal/config (Load, walk-up search, .git boundary stop)
New files: cmd/glint/filter.go (applyConfig, isSuppressed helpers)
Tests: config_test.go, parser_suppress_test.go, filter_test.go
Validate fixtures: testdata/config_ignored/, config_severity/, config_suppress/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/config"
|
||||
"git.k3nny.fr/glint/internal/linter"
|
||||
)
|
||||
|
||||
// applyConfig filters and adjusts findings according to the project config
|
||||
// and inline suppression comments parsed from the pipeline YAML.
|
||||
//
|
||||
// Processing order:
|
||||
// 1. Build a combined ignore set from config.Ignore and any severity entry
|
||||
// whose value is "ignore".
|
||||
// 2. Drop findings whose rule is in the ignore set.
|
||||
// 3. Drop findings suppressed by an inline "# glint: ignore" comment on the
|
||||
// job definition (from p.Suppressions).
|
||||
// 4. Apply severity overrides ("error" / "warning") from config.Severity.
|
||||
func applyConfig(findings []linter.Finding, cfg config.Config, suppressions map[string][]string) []linter.Finding {
|
||||
// Build the global ignore set (uppercased rule IDs).
|
||||
ignoreSet := make(map[string]bool, len(cfg.Ignore))
|
||||
for _, r := range cfg.Ignore {
|
||||
ignoreSet[strings.ToUpper(r)] = true
|
||||
}
|
||||
// Severity entries with value "ignore" are equivalent to Ignore entries.
|
||||
sevMap := make(map[string]string, len(cfg.Severity)) // upperRule → lowerLevel
|
||||
for rule, sev := range cfg.Severity {
|
||||
upper := strings.ToUpper(rule)
|
||||
lower := strings.ToLower(sev)
|
||||
sevMap[upper] = lower
|
||||
if lower == "ignore" {
|
||||
ignoreSet[upper] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(ignoreSet) == 0 && len(sevMap) == 0 && len(suppressions) == 0 {
|
||||
return findings
|
||||
}
|
||||
|
||||
kept := findings[:0:0] // reuse underlying array but return fresh slice
|
||||
for _, f := range findings {
|
||||
ruleUpper := strings.ToUpper(f.Rule)
|
||||
|
||||
// Global ignore.
|
||||
if ignoreSet[ruleUpper] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Inline suppression.
|
||||
if isSuppressed(f.Job, ruleUpper, suppressions) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Severity override.
|
||||
if level, ok := sevMap[ruleUpper]; ok {
|
||||
switch level {
|
||||
case "error":
|
||||
f.Severity = linter.Error
|
||||
case "warning":
|
||||
f.Severity = linter.Warning
|
||||
}
|
||||
}
|
||||
|
||||
kept = append(kept, f)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// isSuppressed reports whether jobName has a "# glint: ignore" directive that
|
||||
// covers ruleUpper. The wildcard entry "*" (from "# glint: ignore all")
|
||||
// suppresses every rule.
|
||||
func isSuppressed(jobName, ruleUpper string, suppressions map[string][]string) bool {
|
||||
if jobName == "" || len(suppressions) == 0 {
|
||||
return false
|
||||
}
|
||||
rules, ok := suppressions[jobName]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r == "*" || r == ruleUpper {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/config"
|
||||
"git.k3nny.fr/glint/internal/linter"
|
||||
)
|
||||
|
||||
func TestApplyConfig(t *testing.T) {
|
||||
findings := []linter.Finding{
|
||||
{Severity: linter.Error, Rule: "GL004", Job: "deploy", File: "ci.yml", Line: 10, Message: "bad stage"},
|
||||
{Severity: linter.Warning, Rule: "GL007", Job: "old-job", File: "ci.yml", Line: 20, Message: "deprecated"},
|
||||
{Severity: linter.Warning, Rule: "GL032", Job: "check", File: "ci.yml", Line: 30, Message: "var ref"},
|
||||
}
|
||||
|
||||
t.Run("no config — pass through", func(t *testing.T) {
|
||||
got := applyConfig(findings, config.Config{}, nil)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("got %d findings, want 3", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignore GL007", func(t *testing.T) {
|
||||
cfg := config.Config{Ignore: []string{"GL007"}}
|
||||
got := applyConfig(findings, cfg, nil)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d findings, want 2", len(got))
|
||||
}
|
||||
for _, f := range got {
|
||||
if f.Rule == "GL007" {
|
||||
t.Error("GL007 should be suppressed")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignore case-insensitive", func(t *testing.T) {
|
||||
cfg := config.Config{Ignore: []string{"gl007"}}
|
||||
got := applyConfig(findings, cfg, nil)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("severity demote error to warning", func(t *testing.T) {
|
||||
cfg := config.Config{Severity: map[string]string{"GL004": "warning"}}
|
||||
got := applyConfig(findings, cfg, nil)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d findings, want 3", len(got))
|
||||
}
|
||||
if got[0].Severity != linter.Warning {
|
||||
t.Errorf("GL004 severity = %s, want WARNING", got[0].Severity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("severity promote warning to error", func(t *testing.T) {
|
||||
cfg := config.Config{Severity: map[string]string{"GL007": "error"}}
|
||||
got := applyConfig(findings, cfg, nil)
|
||||
if got[1].Severity != linter.Error {
|
||||
t.Errorf("GL007 severity = %s, want ERROR", got[1].Severity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("severity ignore is equivalent to ignore list", func(t *testing.T) {
|
||||
cfg := config.Config{Severity: map[string]string{"GL032": "ignore"}}
|
||||
got := applyConfig(findings, cfg, nil)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
for _, f := range got {
|
||||
if f.Rule == "GL032" {
|
||||
t.Error("GL032 should be suppressed via severity=ignore")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline suppression by job", func(t *testing.T) {
|
||||
suppressions := map[string][]string{
|
||||
"old-job": {"GL007"},
|
||||
}
|
||||
got := applyConfig(findings, config.Config{}, suppressions)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
for _, f := range got {
|
||||
if f.Job == "old-job" && f.Rule == "GL007" {
|
||||
t.Error("old-job GL007 should be suppressed")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline suppression wildcard", func(t *testing.T) {
|
||||
suppressions := map[string][]string{
|
||||
"old-job": {"*"},
|
||||
}
|
||||
got := applyConfig(findings, config.Config{}, suppressions)
|
||||
// old-job had GL007; should be gone
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pipeline-level findings not suppressed by job comment", func(t *testing.T) {
|
||||
pipelineFindings := []linter.Finding{
|
||||
{Severity: linter.Error, Rule: "GL001", Job: "", File: "ci.yml", Line: 0, Message: "no stages"},
|
||||
}
|
||||
suppressions := map[string][]string{
|
||||
"": {"GL001"}, // empty job key should not match pipeline-level
|
||||
}
|
||||
got := applyConfig(pipelineFindings, config.Config{}, suppressions)
|
||||
// Pipeline-level findings (f.Job == "") are never suppressed by job comments.
|
||||
if len(got) != 1 {
|
||||
t.Errorf("got %d, want 1 (pipeline-level finding must not be suppressed)", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
+37
-3
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/cicontext"
|
||||
"git.k3nny.fr/glint/internal/config"
|
||||
"git.k3nny.fr/glint/internal/fetcher"
|
||||
"git.k3nny.fr/glint/internal/graph"
|
||||
"git.k3nny.fr/glint/internal/linter"
|
||||
@@ -193,14 +194,34 @@ Examples:
|
||||
os.Exit(2)
|
||||
}
|
||||
path := fs.Arg(0)
|
||||
rootDir := filepath.Dir(filepath.Clean(path))
|
||||
|
||||
// --offline with no explicit --cache-dir defaults to ~/.cache/glint.
|
||||
// Load project config (.glint.yml), searching from the pipeline directory
|
||||
// up to the git root.
|
||||
glintCfg, cfgErr := config.Load(rootDir)
|
||||
if cfgErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
|
||||
}
|
||||
|
||||
// CLI flags take priority over config file values, which take priority over
|
||||
// environment variables (read by AutoConfig).
|
||||
fetcherToken := *token
|
||||
if fetcherToken == "" {
|
||||
fetcherToken = glintCfg.Token
|
||||
}
|
||||
fetcherURL := *gitlabURL
|
||||
if fetcherURL == "" {
|
||||
fetcherURL = glintCfg.URL
|
||||
}
|
||||
resolvedCacheDir := *cacheDir
|
||||
if resolvedCacheDir == "" {
|
||||
resolvedCacheDir = glintCfg.CacheDir
|
||||
}
|
||||
if *offline && resolvedCacheDir == "" {
|
||||
resolvedCacheDir = defaultCacheDir()
|
||||
}
|
||||
|
||||
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
|
||||
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline)
|
||||
|
||||
p, err := model.Parse(path)
|
||||
if err != nil {
|
||||
@@ -208,7 +229,19 @@ Examples:
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
rootDir := filepath.Dir(filepath.Clean(path))
|
||||
// Merge config-defined stages into the pipeline before linting so that
|
||||
// GL004 does not fire for jobs in stages declared only in .glint.yml.
|
||||
stageSet := make(map[string]bool, len(p.Stages))
|
||||
for _, s := range p.Stages {
|
||||
stageSet[s] = true
|
||||
}
|
||||
for _, s := range glintCfg.Stages {
|
||||
if !stageSet[s] {
|
||||
p.Stages = append(p.Stages, s)
|
||||
stageSet[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
|
||||
for _, w := range warnings {
|
||||
fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
|
||||
@@ -239,6 +272,7 @@ Examples:
|
||||
}
|
||||
|
||||
findings := linter.Lint(p)
|
||||
findings = applyConfig(findings, glintCfg, p.Suppressions)
|
||||
errCount, _ := countSeverities(findings)
|
||||
|
||||
// In structured formats the summary line goes to stderr so stdout is clean.
|
||||
|
||||
Reference in New Issue
Block a user