Files
glint/internal/linter/keywords_test.go
T
k3nny 04f17f8616
ci / vet, staticcheck, test, build (push) Successful in 2m25s
test(coverage): add unit tests across all packages; remove dead code
- Added comprehensive table-driven test suites for all packages:
  cmd/glint, cicontext, fetcher, graph, linter, model, resolver.
  Coverage reaches 98%+ statement coverage across the codebase.
- Replaced os.Exit calls in cmd/glint with an `exit` variable so tests
  can capture exit codes without terminating the test process.
- Removed unreachable code found during coverage analysis:
  dead guard in cicontext.parseRegexLiteral; dead len(jobs)==0 branch
  in graph.Pipeline; skipWin struct field and dead continue in
  graph.convertToPNG; pipelineSVG return type simplified to string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:03:46 +02:00

598 lines
27 KiB
Go

package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── checkWhen ─────────────────────────────────────────────────────────────────
func TestCheckWhen(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
wantRule string
}{
{"valid when", model.Job{When: "on_success"}, 0, ""},
{"invalid when", model.Job{When: "bad_value"}, 1, RuleInvalidWhen},
{"delayed requires start_in", model.Job{When: "delayed"}, 1, RuleDelayedNoStartIn},
{"delayed with start_in", model.Job{When: "delayed", StartIn: "30 minutes"}, 0, ""},
{"start_in without delayed", model.Job{When: "on_success", StartIn: "5m"}, 1, RuleStartInNoDelayed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkWhen("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
if tc.wantRule != "" && len(got) > 0 && got[0].Rule != tc.wantRule {
t.Errorf("rule: got %q want %q", got[0].Rule, tc.wantRule)
}
})
}
}
// ── checkParallel ─────────────────────────────────────────────────────────────
func TestCheckParallel(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil parallel", model.Job{}, 0},
{"valid int", model.Job{Parallel: 4}, 0},
{"int too low", model.Job{Parallel: 1}, 1},
{"int too high", model.Job{Parallel: 201}, 1},
{"map with matrix", model.Job{Parallel: map[string]any{"matrix": []any{}}}, 0},
{"map without matrix", model.Job{Parallel: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Parallel: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkParallel("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkRetry ────────────────────────────────────────────────────────────────
func TestCheckRetry(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil retry", model.Job{}, 0},
{"valid int 0", model.Job{Retry: 0}, 0},
{"valid int 2", model.Job{Retry: 2}, 0},
{"invalid int -1", model.Job{Retry: -1}, 1},
{"invalid int 3", model.Job{Retry: 3}, 1},
{"map with valid max", model.Job{Retry: map[string]any{"max": 1}}, 0},
{"map with invalid max", model.Job{Retry: map[string]any{"max": 5}}, 1},
{"map with valid when string", model.Job{Retry: map[string]any{"when": "always"}}, 0},
{"map with invalid when string", model.Job{Retry: map[string]any{"when": "bad_reason"}}, 1},
{"map with when slice", model.Job{Retry: map[string]any{"when": []any{"always", "script_failure"}}}, 0},
{"map with when slice invalid", model.Job{Retry: map[string]any{"when": []any{"bad_reason"}}}, 1},
{"invalid type", model.Job{Retry: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkRetry("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkAllowFailure ─────────────────────────────────────────────────────────
func TestCheckAllowFailure(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil", model.Job{}, 0},
{"bool true", model.Job{Allow: true}, 0},
{"bool false", model.Job{Allow: false}, 0},
{"map with exit_codes", model.Job{Allow: map[string]any{"exit_codes": []any{1, 2}}}, 0},
{"map without exit_codes", model.Job{Allow: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Allow: "yes"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkAllowFailure("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkInterruptible ────────────────────────────────────────────────────────
func TestCheckInterruptible(t *testing.T) {
if len(checkInterruptible("j", model.Job{})) != 0 { t.Error("nil: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: true})) != 0 { t.Error("bool true: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: false})) != 0 { t.Error("bool false: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: "yes"})) != 1 { t.Error("string: expected error") }
}
// ── checkTrigger ──────────────────────────────────────────────────────────────
func TestCheckTrigger(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil trigger", model.Job{}, 0},
{"string trigger", model.Job{Trigger: "other/project"}, 0},
{"trigger with script", model.Job{Trigger: "x", Script: []any{"echo"}}, 1},
{"map trigger with project", model.Job{Trigger: map[string]any{"project": "g/p"}}, 0},
{"map trigger with include", model.Job{Trigger: map[string]any{"include": "ci.yml"}}, 0},
{"map trigger missing both", model.Job{Trigger: map[string]any{"strategy": "depend"}}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkTrigger("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkCoverage ─────────────────────────────────────────────────────────────
func TestCheckCoverage(t *testing.T) {
if len(checkCoverage("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkCoverage("j", model.Job{Coverage: `/\d+%/`})) != 0 { t.Error("valid: clean") }
if len(checkCoverage("j", model.Job{Coverage: "bad"})) != 1 { t.Error("missing slashes: error") }
}
// ── checkRelease ──────────────────────────────────────────────────────────────
func TestCheckRelease(t *testing.T) {
if len(checkRelease("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkRelease("j", model.Job{Release: "string"})) != 1 { t.Error("string: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": "v1"}})) != 0 { t.Error("valid map: clean") }
if len(checkRelease("j", model.Job{Release: map[string]any{"description": "x"}})) != 1 { t.Error("no tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": ""}})) != 1 { t.Error("empty tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": nil}})) != 1 { t.Error("nil tag_name: error") }
}
// ── checkEnvironment ──────────────────────────────────────────────────────────
func TestCheckEnvironment(t *testing.T) {
if len(checkEnvironment("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkEnvironment("j", model.Job{Environment: "production"})) != 0 { t.Error("string: clean") }
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod"}})) != 0 { t.Error("map with name: clean") }
// url without name
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"url": "https://x.com"}})) != 1 { t.Error("url no name: error") }
// invalid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "badaction"}})) != 1 { t.Error("bad action: error") }
// valid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "stop"}})) != 0 { t.Error("stop action: clean") }
}
// ── checkArtifacts ────────────────────────────────────────────────────────────
func TestCheckArtifacts(t *testing.T) {
if len(checkArtifacts("j", model.Job{})) != 0 { t.Error("nil: clean") }
// non-map type: no-op
if len(checkArtifacts("j", model.Job{Artifacts: "string"})) != 0 { t.Error("string: clean") }
// valid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "on_failure"}})) != 0 { t.Error("valid when: clean") }
// invalid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "bad_when"}})) != 1 { t.Error("bad when: error") }
// expose_as without paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "Coverage"}})) != 1 { t.Error("expose_as no paths: error") }
// expose_as with paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "X", "paths": []any{"out/"}}})) != 0 { t.Error("expose_as with paths: clean") }
// pages job without public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 1 { t.Error("pages without public: warning") }
// pages job with public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"public"}}})) != 0 { t.Error("pages with public: clean") }
// pages job with pages: keyword set — GL033 check skipped
if len(checkArtifacts("pages", model.Job{Pages: map[string]any{}, Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 0 { t.Error("pages with pages keyword: clean") }
}
// ── checkCache ────────────────────────────────────────────────────────────────
func TestCheckCache(t *testing.T) {
if len(checkCache("j", model.Job{})) != 0 { t.Error("nil: clean") }
// map form valid
if len(checkCache("j", model.Job{Cache: map[string]any{"key": "abc"}})) != 0 { t.Error("map valid: clean") }
// map form invalid when
if len(checkCache("j", model.Job{Cache: map[string]any{"when": "bad"}})) != 1 { t.Error("invalid when: error") }
// map form invalid policy
if len(checkCache("j", model.Job{Cache: map[string]any{"policy": "bad_policy"}})) != 1 { t.Error("invalid policy: error") }
// slice form
if len(checkCache("j", model.Job{Cache: []any{
map[string]any{"when": "bad"},
map[string]any{"policy": "pull"},
}})) != 1 { t.Error("slice with one bad: error") }
}
// ── checkRules ────────────────────────────────────────────────────────────────
func TestCheckRules(t *testing.T) {
if len(checkRules("j", model.Job{})) != 0 { t.Error("no rules: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "on_success"}}})) != 0 { t.Error("valid when: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "bad_when"}}})) != 1 { t.Error("bad when: error") }
}
// ── checkImage ────────────────────────────────────────────────────────────────
func TestCheckImage(t *testing.T) {
if len(checkImage("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkImage("j", model.Job{Image: "alpine"})) != 0 { t.Error("string: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"name": "alpine"}})) != 0 { t.Error("map with name: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"pull_policy": "always"}})) != 1 { t.Error("map without name: error") }
}
// ── checkInherit ──────────────────────────────────────────────────────────────
func TestCheckInherit(t *testing.T) {
if len(checkInherit("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkInherit("j", model.Job{Inherit: "string"})) != 0 { t.Error("non-map: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": true}})) != 0 { t.Error("bool: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": []any{"image"}}})) != 0 { t.Error("list: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"variables": "string"}})) != 1 { t.Error("invalid type: error") }
}
// ── checkServices ─────────────────────────────────────────────────────────────
func TestCheckServices(t *testing.T) {
if len(checkServices("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkServices("j", model.Job{Services: []any{"postgres"}})) != 0 { t.Error("string: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg"}}})) != 0 { t.Error("valid map: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"port": 5432}}})) != 1 { t.Error("no name: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "invalid alias!"}}})) != 1 { t.Error("bad alias: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "valid-alias"}}})) != 0 { t.Error("good alias: clean") }
}
// ── checkRulesGlobs ───────────────────────────────────────────────────────────
func TestCheckRulesGlobs(t *testing.T) {
if len(checkRulesGlobs("j", model.Job{})) != 0 { t.Error("no rules: clean") }
// Relative path: clean
relRule := model.Rule{Changes: []any{"src/**/*.go"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{relRule}})) != 0 { t.Error("relative: clean") }
// Absolute path: warning
absRule := model.Rule{Changes: []any{"/absolute/path"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{absRule}})) != 1 { t.Error("absolute: warning") }
// Map form with paths
mapRule := model.Rule{Changes: map[string]any{"paths": []any{"/abs"}}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{mapRule}})) != 1 { t.Error("map absolute: warning") }
// exists
existsRule := model.Rule{Exists: []any{"/bad"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{existsRule}})) != 1 { t.Error("exists absolute: warning") }
}
// ── checkTimeout ─────────────────────────────────────────────────────────────
func TestCheckTimeout(t *testing.T) {
if len(checkTimeout("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkTimeout("j", model.Job{Timeout: "1h 30m"})) != 0 { t.Error("valid: clean") }
if len(checkTimeout("j", model.Job{Timeout: "90 minutes"})) != 0 { t.Error("valid2: clean") }
if len(checkTimeout("j", model.Job{Timeout: "not-a-duration"})) != 1 { t.Error("invalid: error") }
}
func TestCheckDefaultTimeout(t *testing.T) {
if len(checkDefaultTimeout("", "f.yml")) != 0 { t.Error("empty: clean") }
if len(checkDefaultTimeout("2 hours", "f.yml")) != 0 { t.Error("valid: clean") }
if len(checkDefaultTimeout("bad", "f.yml")) != 1 { t.Error("invalid: error") }
}
// ── checkIDTokens ─────────────────────────────────────────────────────────────
func TestCheckIDTokens(t *testing.T) {
if len(checkIDTokens("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkIDTokens("j", model.Job{IDTokens: "string"})) != 0 { t.Error("non-map: clean") }
// valid token with aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"aud": "https://example.com"},
}})) != 0 { t.Error("valid: clean") }
// missing aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing aud: error") }
// not a map
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": "string",
}})) != 1 { t.Error("token not a map: error") }
}
// ── checkSecrets ─────────────────────────────────────────────────────────────
func TestCheckSecrets(t *testing.T) {
if len(checkSecrets("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkSecrets("j", model.Job{Secrets: "string"})) != 0 { t.Error("non-map: clean") }
// valid vault
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"vault": map[string]any{"engine": map[string]any{"name": "kv", "path": "x"}, "path": "y", "field": "z"}},
}})) != 0 { t.Error("vault: clean") }
// missing provider
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing provider: error") }
// not a map
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": "string",
}})) != 1 { t.Error("not a map: error") }
}
// ── checkPagesKeyword ─────────────────────────────────────────────────────────
func TestCheckPagesKeyword(t *testing.T) {
if len(checkPagesKeyword("j", model.Job{})) != 0 { t.Error("nil pages: clean") }
// pages keyword but no artifacts
if len(checkPagesKeyword("j", model.Job{Pages: map[string]any{}})) != 1 { t.Error("no artifacts: warning") }
// pages keyword with artifacts but no paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{},
})) != 1 { t.Error("no paths: warning") }
// pages with public in paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 0 { t.Error("public in paths: clean") }
// pages with custom publish dir and matching path
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"dist"}},
})) != 0 { t.Error("custom publish match: clean") }
// pages with custom publish dir missing from paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 1 { t.Error("custom publish missing: warning") }
// artifacts is a non-map (unexpected)
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: "string",
})) != 0 { t.Error("non-map artifacts: clean") }
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles(t *testing.T) {
if len(checkCacheKeyFiles("j", model.Job{})) != 0 { t.Error("nil: clean") }
// valid key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"Gemfile.lock"}},
}})) != 0 { t.Error("valid: clean") }
// glob pattern in key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"*.lock"}},
}})) != 1 { t.Error("glob: warning") }
// files is not a list
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": "Gemfile.lock"},
}})) != 1 { t.Error("non-list: error") }
// no key
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{}})) != 0 { t.Error("no key: clean") }
// key is not a map
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{"key": "string"}})) != 0 { t.Error("key string: clean") }
// slice cache form
if len(checkCacheKeyFiles("j", model.Job{Cache: []any{
map[string]any{"key": map[string]any{"files": []any{"*.lock"}}},
}})) != 1 { t.Error("slice cache with glob: warning") }
}
// ── linter.go level checks ────────────────────────────────────────────────────
func TestCheckStages(t *testing.T) {
// no stages → warning
p := &model.Pipeline{}
if len(checkStages(p)) != 1 { t.Error("no stages: warning") }
// with stages → clean
p2 := &model.Pipeline{Stages: []string{"build"}}
if len(checkStages(p2)) != 0 { t.Error("with stages: clean") }
}
func TestCheckDuplicateStages(t *testing.T) {
p := &model.Pipeline{Stages: []string{"build", "test", "build"}}
if len(checkDuplicateStages(p)) != 1 { t.Error("duplicate: warning") }
p2 := &model.Pipeline{Stages: []string{"build", "test"}}
if len(checkDuplicateStages(p2)) != 0 { t.Error("no duplicate: clean") }
}
func TestCheckWorkflow(t *testing.T) {
p := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "always"}}}}
if len(checkWorkflow(p)) != 0 { t.Error("valid when: clean") }
p2 := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "bad"}}}}
if len(checkWorkflow(p2)) != 1 { t.Error("invalid when: error") }
p3 := &model.Pipeline{}
if len(checkWorkflow(p3)) != 0 { t.Error("nil workflow: clean") }
}
func TestFindingString(t *testing.T) {
f := Finding{
Severity: Error,
Rule: "GL001",
Job: "build",
File: "ci.yml",
Line: 10,
Message: "missing script",
}
s := f.String()
if s == "" { t.Error("expected non-empty string") }
// Without file/line/job
f2 := Finding{Severity: Warning, Rule: "GL002", Message: "test"}
s2 := f2.String()
if s2 == "" { t.Error("expected non-empty string") }
// With file but no line
f3 := Finding{Severity: Error, Rule: "GL003", File: "ci.yml", Message: "x"}
if f3.String() == "" { t.Error("expected non-empty string") }
}
func TestScriptNonEmpty(t *testing.T) {
if scriptNonEmpty(nil) { t.Error("nil") }
if scriptNonEmpty([]any{}) { t.Error("empty slice") }
if !scriptNonEmpty([]any{"echo"}) { t.Error("non-empty slice") }
if !scriptNonEmpty("echo") { t.Error("string") }
if scriptNonEmpty("") { t.Error("empty string") }
}
// ── checkNeeds ────────────────────────────────────────────────────────────────
func TestCheckNeeds(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}},
},
}
if len(checkNeeds(p)) != 0 { t.Error("valid needs: clean") }
// needs unknown job
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
},
}
if len(checkNeeds(p2)) != 1 { t.Error("unknown needs: error") }
// optional: true for unknown job → warning not error
p3 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test",
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
}
findings := checkNeeds(p3)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Error("optional unknown needs: warning")
}
// cross-pipeline need (ignored)
p4 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job",
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
if len(checkNeeds(p4)) != 0 { t.Error("cross-pipeline: clean") }
// needs later stage → error
p5 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"early-job": {Name: "early-job", Stage: "build",
Needs: []any{"build-job"}, Script: []any{"echo"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}}, // ok
},
}
// Only cross-stage ordering violations should be found
_ = checkNeeds(p5)
}
func TestCheckNeeds_Cycle(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"a": {Name: "a", Needs: []any{"b"}},
"b": {Name: "b", Needs: []any{"a"}},
},
}
findings := checkNeeds(p)
for _, f := range findings {
if f.Rule == RuleNeedsCycle { return }
}
t.Error("expected cycle detection finding")
}
// ── checkDependencies ─────────────────────────────────────────────────────────
func TestCheckDependencies(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Dependencies: []string{"build-job"}},
},
}
if len(checkDependencies(p)) != 0 { t.Error("valid deps: clean") }
// unknown dep
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
if len(checkDependencies(p2)) != 1 { t.Error("unknown dep: error") }
// dep in same or later stage → error
p3 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"test-job1": {Name: "test-job1", Stage: "test"},
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
},
}
if len(checkDependencies(p3)) != 1 { t.Error("same stage dep: error") }
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles_NoFilesKey(t *testing.T) {
// cache.key is a map but has no "files" key → continue (line 823-824).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"prefix": "v1"},
},
}
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("key map without 'files': expected no findings, got %v", got)
}
}
func TestCheckCacheKeyFiles_FilesNotList(t *testing.T) {
// cache.key.files is a scalar (not a list) → error finding (line 827-834).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"files": "single-file.lock"},
},
}
got := checkCacheKeyFiles("j", job)
if len(got) == 0 {
t.Error("files as scalar: expected error finding")
return
}
if got[0].Rule != RuleInvalidCacheKeyFiles || got[0].Severity != Error {
t.Errorf("unexpected finding: %v", got[0])
}
}
func TestCheckCacheKeyFiles_NonStringItem(t *testing.T) {
// cache.key.files list has a non-string item → skip it (line 838-839).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{
"files": []any{42, "go.sum"}, // 42 is non-string, go.sum is valid
},
},
}
// 42 is skipped; "go.sum" has no glob chars → no findings.
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("non-string item: expected no findings, got %v", got)
}
}