diff --git a/CHANGELOG.md b/CHANGELOG.md index 785237b..781afe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.2.19] - 2026-06-14 + +### Added + +- **`.glint.yml` project config file** — glint now searches for a `.glint.yml` file starting from the pipeline file's directory and walking up to the first `.git` boundary. Supported keys: + - `ignore: [GL007, GL032]` — suppress rules globally for the project. + - `severity: {GL004: warning}` — override rule severity (`error`, `warning`, or `ignore`). `ignore` is equivalent to listing the rule in `ignore:`. + - `stages: [quality]` — declare extra stage names that are valid beyond those in the pipeline's own `stages:` block; jobs in these stages are not flagged by GL004. + - `token: glpat-xxx` — default GitLab personal access token (lower priority than the `--token` flag and `GITLAB_TOKEN` env var). + - `url: https://gitlab.example.com` — default GitLab instance URL. + - `cache_dir: ~/.cache/glint` — default cache directory for fetched remote includes. + +- **Inline suppression comments** — a `# glint: ignore RULE` comment placed immediately before a job definition suppresses that rule for the specific job. Multiple rules can be comma- or space-separated (`# glint: ignore GL007, GL032`). Use `# glint: ignore all` to suppress every finding for the job. Suppressions are scoped to the annotated job; pipeline-level findings are unaffected. + ## [0.2.18] - 2026-06-14 ### Added diff --git a/README.md b/README.md index 1aa2c7e..a649be3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. @@ -43,6 +43,8 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G - **`include: inputs:` substitution** — when a `component:` entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in the fetched template are substituted before parsing, so component-scoped jobs get their correct `stage:` and keyword values instead of `$[[…]]` placeholders - **Offline mode + include cache** — pass `--cache-dir DIR` to cache fetched remote templates (project: and component: includes) to disk; `--offline` serves entirely from the cache without making network calls - **Structured output formats** — `--format json` emits a stable JSON report; `--format sarif` emits SARIF 2.1.0 (consumed by GitHub Code Scanning and GitLab SAST); `--format junit` emits JUnit XML (consumable as a CI test-report artifact); `--format github` emits GitHub Actions annotation lines (`::error file=…::`) so findings appear as inline PR comments +- **`.glint.yml` project config** — rule suppression (`ignore: [GL007]`), severity overrides (`severity: {GL004: warning}`), extra stages allowlist (`stages: [quality]`), and default token/URL/cache-dir so flags are not needed on every invocation +- **Inline suppression comments** — `# glint: ignore GL007` (or `# glint: ignore all`) immediately before a job definition suppresses the specified rule(s) for that job without touching other jobs - **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first - **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings - **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output @@ -132,6 +134,68 @@ so stdout contains only the machine-readable payload. ::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages' ``` +### Project configuration (`.glint.yml`) + +Place a `.glint.yml` file next to your pipeline (or anywhere in the directory tree up to the repository root) to configure glint for that project. glint searches upward from the pipeline file's directory, stopping at the first `.git` boundary. + +```yaml +# .glint.yml + +# Suppress specific rules entirely. +ignore: + - GL007 # we still use only:/except:, migration in progress + - GL032 # lots of dynamic variables injected by CI + +# Override the severity of specific rules. +severity: + GL004: warning # demote stage errors to warnings during a migration + GL035: error # promote absolute-path warning to error for this project + +# Extra stages that are valid but not declared in the pipeline YAML itself +# (e.g. injected by an include template we can't edit). +stages: + - quality + - security + +# Default token — overridden by --token flag and GITLAB_TOKEN env. +token: glpat-xxxx + +# Default GitLab instance URL. +url: https://gitlab.example.com + +# Default cache directory for fetched remote includes. +cache_dir: ~/.cache/glint +``` + +**Priority chain for token and URL:** `--token`/`--gitlab-url` flags > `.glint.yml` values > `GITLAB_TOKEN`/`CI_SERVER_URL` environment variables. + +### Inline suppression (`# glint: ignore`) + +Suppress a finding for a specific job by placing a `# glint: ignore RULE` comment immediately before the job definition: + +```yaml +# glint: ignore GL007 +legacy-job: + stage: build + only: + - main + script: echo ok + +# Multiple rules — comma- or space-separated: +# glint: ignore GL007, GL032 +another-job: + stage: build + script: echo ok + +# Suppress all rules for this job: +# glint: ignore all +noisy-job: + stage: build + script: echo ok +``` + +Inline suppressions are scoped to the single job they precede. They do not affect other jobs or pipeline-level findings. For project-wide suppression use `.glint.yml` `ignore:`. + ### Remote project includes Pipelines that include templates from other GitLab projects are supported. diff --git a/ROADMAP.md b/ROADMAP.md index 87fc3de..3f20d23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,14 +132,10 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it --- -## Configuration +## Configuration — ✓ shipped v0.2.19 -- **`.glint.yml` config file** — project-level configuration for: - - Rule suppression by rule ID (e.g. `ignore: [no-only, missing-stages]`) - - Severity overrides (demote specific errors to warnings) - - Custom `stages` allowlist for projects that use a non-standard default set - - Token and URL defaults so flags are not needed in every invocation -- **Inline suppression comments** — `# glint: ignore next-line ` in the pipeline YAML +- ~~**`.glint.yml` config file**~~ — ✓ shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root +- ~~**Inline suppression comments**~~ — ✓ shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard --- diff --git a/Taskfile.yml b/Taskfile.yml index 8f15d5e..228cfe6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -101,6 +101,12 @@ tasks: ignore_error: false - cmd: ./{{.BINARY}} check --format github testdata/invalid.yml ignore_error: true + - cmd: ./{{.BINARY}} check testdata/config_ignored/.gitlab-ci.yml + ignore_error: false + - cmd: ./{{.BINARY}} check testdata/config_severity/.gitlab-ci.yml + ignore_error: false + - cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml + ignore_error: false lint-go: desc: Run go vet on all packages diff --git a/cmd/glint/filter.go b/cmd/glint/filter.go new file mode 100644 index 0000000..469a9c6 --- /dev/null +++ b/cmd/glint/filter.go @@ -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 +} diff --git a/cmd/glint/filter_test.go b/cmd/glint/filter_test.go new file mode 100644 index 0000000..ef435a2 --- /dev/null +++ b/cmd/glint/filter_test.go @@ -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)) + } + }) +} diff --git a/cmd/glint/main.go b/cmd/glint/main.go index debf90b..7af60e0 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -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. diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5bda0f3 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..04e2b64 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/model/parser.go b/internal/model/parser.go index 10ca1cb..586de2c 100644 --- a/internal/model/parser.go +++ b/internal/model/parser.go @@ -3,6 +3,7 @@ package model import ( "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) @@ -60,6 +61,14 @@ func ParseBytes(data []byte) (*Pipeline, error) { continue } + // Extract any inline suppression directive from the job's head or line comment. + if rules := parseSuppressComment(keyNode.HeadComment, keyNode.LineComment); len(rules) > 0 { + if p.Suppressions == nil { + p.Suppressions = map[string][]string{} + } + p.Suppressions[key] = rules + } + var rawMap map[string]any if err := valNode.Decode(&rawMap); err != nil { return nil, fmt.Errorf("parsing raw job %q: %w", key, err) @@ -78,6 +87,39 @@ func ParseBytes(data []byte) (*Pipeline, error) { return p, nil } +// parseSuppressComment scans head/line comments from a YAML key node for a +// "# glint: ignore RULE [RULE ...]" directive. Returns the list of rule IDs +// to suppress (uppercased), or []string{"*"} for "# glint: ignore all". +// Returns nil when no directive is found. +func parseSuppressComment(headComment, lineComment string) []string { + for _, raw := range []string{headComment, lineComment} { + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimLeft(line, "# \t") + if !strings.HasPrefix(line, "glint:") { + continue + } + rest := strings.TrimSpace(strings.TrimPrefix(line, "glint:")) + if !strings.HasPrefix(rest, "ignore") { + continue + } + rest = strings.TrimSpace(strings.TrimPrefix(rest, "ignore")) + if rest == "" || strings.EqualFold(rest, "all") { + return []string{"*"} + } + var rules []string + for _, part := range strings.FieldsFunc(rest, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' + }) { + if p := strings.TrimSpace(part); p != "" { + rules = append(rules, strings.ToUpper(p)) + } + } + return rules + } + } + return nil +} + // sanitizeYAMLEscapes rewrites double-quoted YAML strings, replacing the \/ // escape sequence (unrecognised by gopkg.in/yaml.v3) with \\/ so that the // parser produces a literal backslash+slash — preserving regex patterns like diff --git a/internal/model/parser_suppress_test.go b/internal/model/parser_suppress_test.go new file mode 100644 index 0000000..0d5a454 --- /dev/null +++ b/internal/model/parser_suppress_test.go @@ -0,0 +1,125 @@ +package model + +import "testing" + +func TestParseSuppressComment(t *testing.T) { + cases := []struct { + name string + head string + line string + wantRules []string + wantNil bool + }{ + { + name: "no comment", + head: "", + line: "", + wantNil: true, + }, + { + name: "single rule head comment", + head: "# glint: ignore GL007", + wantRules: []string{"GL007"}, + }, + { + name: "multiple rules comma-separated", + head: "# glint: ignore GL007, GL032", + wantRules: []string{"GL007", "GL032"}, + }, + { + name: "multiple rules space-separated", + head: "# glint: ignore GL007 GL032", + wantRules: []string{"GL007", "GL032"}, + }, + { + name: "ignore all", + head: "# glint: ignore all", + wantRules: []string{"*"}, + }, + { + name: "ignore all bare", + head: "# glint: ignore", + wantRules: []string{"*"}, + }, + { + name: "lowercase rule ID is uppercased", + head: "# glint: ignore gl007", + wantRules: []string{"GL007"}, + }, + { + name: "line comment", + head: "", + line: "# glint: ignore GL004", + wantRules: []string{"GL004"}, + }, + { + name: "unrelated comment", + head: "# This job deploys to production", + wantNil: true, + }, + { + name: "partial match - not a glint directive", + head: "# hint: ignore this", + wantNil: true, + }, + { + name: "multi-line head comment with directive on second line", + head: "# Some description\n# glint: ignore GL007", + wantRules: []string{"GL007"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseSuppressComment(tc.head, tc.line) + if tc.wantNil { + if got != nil { + t.Errorf("expected nil, got %v", got) + } + return + } + if len(got) != len(tc.wantRules) { + t.Fatalf("got %v, want %v", got, tc.wantRules) + } + for i, r := range tc.wantRules { + if got[i] != r { + t.Errorf("[%d] got %q, want %q", i, got[i], r) + } + } + }) + } +} + +func TestParseBytes_Suppressions(t *testing.T) { + yaml := ` +stages: + - build + +# glint: ignore GL007 +deprecated-job: + stage: build + only: + - main + +normal-job: + stage: build + script: echo ok +` + p, err := ParseBytes([]byte(yaml)) + if err != nil { + t.Fatalf("ParseBytes: %v", err) + } + if p.Suppressions == nil { + t.Fatal("Suppressions is nil") + } + rules, ok := p.Suppressions["deprecated-job"] + if !ok { + t.Fatal("no suppression for deprecated-job") + } + if len(rules) != 1 || rules[0] != "GL007" { + t.Errorf("rules = %v, want [GL007]", rules) + } + if _, ok := p.Suppressions["normal-job"]; ok { + t.Error("normal-job should have no suppressions") + } +} diff --git a/internal/model/pipeline.go b/internal/model/pipeline.go index 7d4c37d..a88cb69 100644 --- a/internal/model/pipeline.go +++ b/internal/model/pipeline.go @@ -12,6 +12,10 @@ type Pipeline struct { // Jobs holds every non-reserved top-level key (i.e. job definitions). Jobs map[string]Job `yaml:"-"` RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver + // Suppressions maps job names to lists of suppressed rule IDs parsed from + // "# glint: ignore RULE" comments in the pipeline YAML. Only populated for + // the root pipeline file (not for included templates). + Suppressions map[string][]string `yaml:"-"` } // SetJobOrigin sets the File field on all jobs that don't already have one. diff --git a/testdata/config_ignored/.gitlab-ci.yml b/testdata/config_ignored/.gitlab-ci.yml new file mode 100644 index 0000000..add53a8 --- /dev/null +++ b/testdata/config_ignored/.gitlab-ci.yml @@ -0,0 +1,10 @@ +stages: + - build + +# GL007 (only/except deprecated) would normally fire here, but the .glint.yml +# in this directory ignores GL007. +deprecated-job: + stage: build + only: + - main + script: echo ok diff --git a/testdata/config_ignored/.glint.yml b/testdata/config_ignored/.glint.yml new file mode 100644 index 0000000..92cb2f0 --- /dev/null +++ b/testdata/config_ignored/.glint.yml @@ -0,0 +1,2 @@ +ignore: + - GL007 diff --git a/testdata/config_severity/.gitlab-ci.yml b/testdata/config_severity/.gitlab-ci.yml new file mode 100644 index 0000000..b507452 --- /dev/null +++ b/testdata/config_severity/.gitlab-ci.yml @@ -0,0 +1,8 @@ +stages: + - build + +# GL004 (undefined stage) is demoted to warning by .glint.yml, so the exit +# code must be 0 even though there is a finding. +bad-stage-job: + stage: nonexistent + script: echo hi diff --git a/testdata/config_severity/.glint.yml b/testdata/config_severity/.glint.yml new file mode 100644 index 0000000..2e6d350 --- /dev/null +++ b/testdata/config_severity/.glint.yml @@ -0,0 +1,2 @@ +severity: + GL004: warning diff --git a/testdata/config_suppress/.gitlab-ci.yml b/testdata/config_suppress/.gitlab-ci.yml new file mode 100644 index 0000000..d7e7e49 --- /dev/null +++ b/testdata/config_suppress/.gitlab-ci.yml @@ -0,0 +1,15 @@ +stages: + - build + +# glint: ignore GL007 +suppressed-job: + stage: build + only: + - main + script: echo ok + +still-flagged-job: + stage: build + only: + - main + script: echo ok