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,75 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Filename is the name of the project-level glint configuration file.
|
||||
const Filename = ".glint.yml"
|
||||
|
||||
// Config holds project-level glint configuration loaded from .glint.yml.
|
||||
type Config struct {
|
||||
// Ignore lists rule IDs to suppress entirely (e.g. ["GL007", "GL032"]).
|
||||
Ignore []string `yaml:"ignore"`
|
||||
|
||||
// Severity maps rule IDs to overridden severity levels.
|
||||
// Valid values: "error", "warning", "ignore".
|
||||
// "ignore" is equivalent to listing the rule in Ignore.
|
||||
Severity map[string]string `yaml:"severity"`
|
||||
|
||||
// Stages lists additional stage names that are considered valid for this
|
||||
// project, beyond what is declared in the pipeline's own stages: block.
|
||||
// Jobs in these stages are not flagged by GL004.
|
||||
Stages []string `yaml:"stages"`
|
||||
|
||||
// Token is a default GitLab personal access token used when neither the
|
||||
// --token flag nor GITLAB_TOKEN (/ CI_JOB_TOKEN / GITLAB_PRIVATE_TOKEN)
|
||||
// environment variables are set.
|
||||
Token string `yaml:"token"`
|
||||
|
||||
// URL is the default GitLab instance URL. Overridden by --gitlab-url and
|
||||
// the CI_SERVER_URL / GITLAB_URL environment variables.
|
||||
URL string `yaml:"url"`
|
||||
|
||||
// CacheDir is the default directory for caching fetched remote includes.
|
||||
// Overridden by the --cache-dir flag.
|
||||
CacheDir string `yaml:"cache_dir"`
|
||||
}
|
||||
|
||||
// Load searches for a .glint.yml file starting from dir and walking up toward
|
||||
// the filesystem root. The walk stops at the first .git directory found (the
|
||||
// repository root) or at the filesystem root. Returns an empty Config (and no
|
||||
// error) when no config file is found.
|
||||
func Load(dir string) (Config, error) {
|
||||
dir = filepath.Clean(dir)
|
||||
for {
|
||||
candidate := filepath.Join(dir, Filename)
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err == nil {
|
||||
var cfg Config
|
||||
if yerr := yaml.Unmarshal(data, &cfg); yerr != nil {
|
||||
return Config{}, fmt.Errorf("parsing %s: %w", candidate, yerr)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("reading %s: %w", candidate, err)
|
||||
}
|
||||
|
||||
// Stop when we reach a git root so we don't wander into parent repos.
|
||||
if _, serr := os.Stat(filepath.Join(dir, ".git")); serr == nil {
|
||||
break
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break // filesystem root
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return Config{}, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad_NotFound(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg, err := Load(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Ignore) != 0 || cfg.Token != "" || cfg.URL != "" {
|
||||
t.Errorf("expected empty config, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Found(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
content := `
|
||||
ignore:
|
||||
- GL007
|
||||
- GL032
|
||||
severity:
|
||||
GL004: warning
|
||||
stages:
|
||||
- quality
|
||||
token: glpat-test
|
||||
url: https://gitlab.example.com
|
||||
cache_dir: /tmp/glint-cache
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cfg.Ignore) != 2 || cfg.Ignore[0] != "GL007" {
|
||||
t.Errorf("ignore = %v", cfg.Ignore)
|
||||
}
|
||||
if cfg.Severity["GL004"] != "warning" {
|
||||
t.Errorf("severity = %v", cfg.Severity)
|
||||
}
|
||||
if len(cfg.Stages) != 1 || cfg.Stages[0] != "quality" {
|
||||
t.Errorf("stages = %v", cfg.Stages)
|
||||
}
|
||||
if cfg.Token != "glpat-test" {
|
||||
t.Errorf("token = %q", cfg.Token)
|
||||
}
|
||||
if cfg.URL != "https://gitlab.example.com" {
|
||||
t.Errorf("url = %q", cfg.URL)
|
||||
}
|
||||
if cfg.CacheDir != "/tmp/glint-cache" {
|
||||
t.Errorf("cache_dir = %q", cfg.CacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WalksUp(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
sub := filepath.Join(tmp, "subdir", "pipeline")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Config at the top-level (no .git, so walk continues to tmp).
|
||||
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("token: walked-up\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(sub)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Token != "walked-up" {
|
||||
t.Errorf("token = %q, want walked-up", cfg.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_StopsAtGitRoot(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
sub := filepath.Join(tmp, "repo", "src")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// .git marks the repo root — walk must stop here.
|
||||
if err := os.Mkdir(filepath.Join(tmp, "repo", ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Config placed ABOVE the .git root should not be found.
|
||||
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("token: should-not-load\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(sub)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Token != "" {
|
||||
t.Errorf("token = %q, should not have crossed .git boundary", cfg.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidYAML(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Load(tmp)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user