2026-06-14 22:03:46 +02:00
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" }},
},
}
2026-06-25 00:25:09 +02:00
if len ( checkNeeds ( p , nil )) != 0 { t . Error ( "valid needs: clean" ) }
2026-06-14 22:03:46 +02:00
// needs unknown job
p2 := & model . Pipeline {
Jobs : map [ string ] model . Job {
"test-job" : { Name : "test-job" , Stage : "test" , Needs : [] any { "nonexistent" }},
},
}
2026-06-25 00:25:09 +02:00
if len ( checkNeeds ( p2 , nil )) != 1 { t . Error ( "unknown needs: error" ) }
2026-06-14 22:03:46 +02:00
// 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 }}},
},
}
2026-06-25 00:25:09 +02:00
findings := checkNeeds ( p3 , nil )
2026-06-14 22:03:46 +02:00
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" }}},
},
}
2026-06-25 00:25:09 +02:00
if len ( checkNeeds ( p4 , nil )) != 0 { t . Error ( "cross-pipeline: clean" ) }
2026-06-14 22:03:46 +02:00
// 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
2026-06-25 00:25:09 +02:00
_ = checkNeeds ( p5 , nil )
2026-06-14 22:03:46 +02:00
}
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" }},
},
}
2026-06-25 00:25:09 +02:00
findings := checkNeeds ( p , nil )
2026-06-14 22:03:46 +02:00
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" }},
},
}
2026-06-25 00:25:09 +02:00
if len ( checkDependencies ( p , nil )) != 0 { t . Error ( "valid deps: clean" ) }
2026-06-14 22:03:46 +02:00
// unknown dep
p2 := & model . Pipeline {
Jobs : map [ string ] model . Job {
"test-job" : { Name : "test-job" , Stage : "test" , Dependencies : [] string { "ghost" }},
},
}
2026-06-25 00:25:09 +02:00
if len ( checkDependencies ( p2 , nil )) != 1 { t . Error ( "unknown dep: error" ) }
2026-06-14 22:03:46 +02:00
// 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" }},
},
}
2026-06-25 00:25:09 +02:00
if len ( checkDependencies ( p3 , nil )) != 1 { t . Error ( "same stage dep: error" ) }
}
func TestCheckDependencies_SkippedJob ( t * testing . T ) {
p := & model . Pipeline {
Jobs : map [ string ] model . Job {
"skipped-job" : { Name : "skipped-job" , Stage : "test" , Dependencies : [] string { "ghost" }},
},
}
// Without skipping: reports unknown dependency.
if len ( checkDependencies ( p , nil )) == 0 {
t . Fatal ( "expected GL030 without skipped set, got none" )
}
// With job skipped: suppressed.
if len ( checkDependencies ( p , map [ string ] bool { "skipped-job" : true })) != 0 {
t . Error ( "expected no findings for skipped job" )
}
2026-06-14 22:03:46 +02:00
}
// ── 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 )
}
}