feat(cli)!: subcommand CLI, graph tree mode, local include resolution
BREAKING CHANGES: - `glint <file>` removed; use `glint check <file>` - `--graph <mode>` removed; use `glint graph [mode]` - `--graph-out` renamed to `--out` on `glint graph` feat(cli): ruff-style subcommands — `glint check` and `glint graph [mode]` feat(graph): `glint graph tree` — terminal job tree with context annotations feat(graph): context flags (--branch/--tag/--source/--var) on `glint graph` feat(resolver): recursive local include resolution from disk fix(resolver): extends unknown base emits warning instead of fatal error fix(model): script/before_script/after_script accept block scalar string form test(linter): Samba project CI fixtures as integration tests chore(build): fix .gitignore to not exclude cmd/glint/ directory docs: update CHANGELOG, README, ROADMAP for v0.2.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+209
-70
@@ -2,12 +2,183 @@ package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/fetcher"
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// Includes returns a Mermaid flowchart showing include file dependencies.
|
||||
// sourcePath is the path to the main pipeline file; rawIncludes is Pipeline.Include.
|
||||
func Includes(sourcePath string, rawIncludes []any) string {
|
||||
// Includes returns a Mermaid flowchart of the full include dependency tree.
|
||||
// It recurses into project:, component:, and local: includes to expose
|
||||
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
|
||||
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
|
||||
b := &treeBuilder{
|
||||
visited: map[string]bool{},
|
||||
cfg: cfg,
|
||||
baseDir: filepath.Dir(sourcePath),
|
||||
}
|
||||
root := &treeNode{id: "root", label: mermaidLabel(sourcePath), class: "main"}
|
||||
b.buildChildren(root, rawIncludes)
|
||||
return renderTree(root)
|
||||
}
|
||||
|
||||
type treeNode struct {
|
||||
id string
|
||||
label string
|
||||
class string
|
||||
children []*treeNode
|
||||
}
|
||||
|
||||
// treeBuilder accumulates state while recursively traversing include entries.
|
||||
type treeBuilder struct {
|
||||
counter int
|
||||
visited map[string]bool // prevents infinite loops on circular includes
|
||||
cfg fetcher.GitLabConfig
|
||||
baseDir string // directory of the current pipeline file; changes for local includes
|
||||
}
|
||||
|
||||
func (b *treeBuilder) nextID() string {
|
||||
b.counter++
|
||||
return fmt.Sprintf("inc%d", b.counter)
|
||||
}
|
||||
|
||||
func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) {
|
||||
for _, entry := range rawIncludes {
|
||||
for _, child := range b.parseEntry(entry) {
|
||||
parent.children = append(parent.children, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *treeBuilder) parseEntry(entry any) []*treeNode {
|
||||
switch v := entry.(type) {
|
||||
case string:
|
||||
node := &treeNode{id: b.nextID(), label: "local: " + mermaidLabel(v), class: "local"}
|
||||
b.recurseLocal(node, v)
|
||||
return []*treeNode{node}
|
||||
case map[string]any:
|
||||
return b.parseMap(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *treeBuilder) parseMap(m map[string]any) []*treeNode {
|
||||
if comp, ok := m["component"].(string); ok {
|
||||
node := &treeNode{
|
||||
id: b.nextID(),
|
||||
label: "component:<br>" + mermaidLabel(comp),
|
||||
class: "component",
|
||||
}
|
||||
b.recurseComponent(node, comp)
|
||||
return []*treeNode{node}
|
||||
}
|
||||
if proj, ok := m["project"].(string); ok {
|
||||
ref, _ := m["ref"].(string)
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
files := includeFileList(m["file"])
|
||||
if len(files) == 0 {
|
||||
return []*treeNode{{
|
||||
id: b.nextID(),
|
||||
label: fmt.Sprintf("project: %s @ %s", mermaidLabel(proj), ref),
|
||||
class: "project",
|
||||
}}
|
||||
}
|
||||
var nodes []*treeNode
|
||||
for _, f := range files {
|
||||
node := &treeNode{
|
||||
id: b.nextID(),
|
||||
label: fmt.Sprintf("project: %s<br>%s @ %s", mermaidLabel(proj), mermaidLabel(f), ref),
|
||||
class: "project",
|
||||
}
|
||||
b.recurseProject(node, proj, f, ref)
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
if local, ok := m["local"].(string); ok {
|
||||
node := &treeNode{id: b.nextID(), label: "local: " + mermaidLabel(local), class: "local"}
|
||||
b.recurseLocal(node, local)
|
||||
return []*treeNode{node}
|
||||
}
|
||||
if remote, ok := m["remote"].(string); ok {
|
||||
return []*treeNode{{id: b.nextID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}}
|
||||
}
|
||||
if tmpl, ok := m["template"].(string); ok {
|
||||
return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *treeBuilder) recurseLocal(node *treeNode, path string) {
|
||||
absPath := filepath.Join(b.baseDir, path)
|
||||
key := "local:" + absPath
|
||||
if b.visited[key] {
|
||||
return
|
||||
}
|
||||
b.visited[key] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p, err := model.ParseBytes(data)
|
||||
if err != nil || len(p.Include) == 0 {
|
||||
return
|
||||
}
|
||||
orig := b.baseDir
|
||||
b.baseDir = filepath.Dir(absPath)
|
||||
b.buildChildren(node, p.Include)
|
||||
b.baseDir = orig
|
||||
}
|
||||
|
||||
func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref string) {
|
||||
key := fmt.Sprintf("project:%s:%s@%s", project, filePath, ref)
|
||||
if b.visited[key] || !b.cfg.HasToken() || filePath == "" {
|
||||
return
|
||||
}
|
||||
b.visited[key] = true
|
||||
|
||||
data, err := b.cfg.FetchFile(project, filePath, ref)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p, err := model.ParseBytes(data)
|
||||
if err != nil || len(p.Include) == 0 {
|
||||
return
|
||||
}
|
||||
b.buildChildren(node, p.Include)
|
||||
}
|
||||
|
||||
func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
|
||||
if strings.ContainsRune(ref, '$') {
|
||||
return
|
||||
}
|
||||
key := "component:" + ref
|
||||
if b.visited[key] {
|
||||
return
|
||||
}
|
||||
b.visited[key] = true
|
||||
|
||||
host, project, component, version, err := parseComponentRef(ref)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data, err := fetchComponentFile(b.cfg.ForHost(host), project, component, version)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p, err := model.ParseBytes(data)
|
||||
if err != nil || len(p.Include) == 0 {
|
||||
return
|
||||
}
|
||||
b.buildChildren(node, p.Include)
|
||||
}
|
||||
|
||||
func renderTree(root *treeNode) string {
|
||||
var sb strings.Builder
|
||||
w := func(s string) { sb.WriteString(s + "\n") }
|
||||
wf := func(f string, a ...any) { fmt.Fprintf(&sb, f+"\n", a...) }
|
||||
@@ -23,84 +194,52 @@ func Includes(sourcePath string, rawIncludes []any) string {
|
||||
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
|
||||
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
|
||||
w("")
|
||||
wf(" root[\"%s\"]:::main", mermaidLabel(sourcePath))
|
||||
|
||||
if len(rawIncludes) == 0 {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
w("")
|
||||
|
||||
counter := 0
|
||||
for _, entry := range rawIncludes {
|
||||
for _, n := range parseIncludeEntry(entry, &counter) {
|
||||
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
|
||||
wf(" root --> %s", n.id)
|
||||
var emit func(n *treeNode)
|
||||
emit = func(n *treeNode) {
|
||||
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
|
||||
for _, child := range n.children {
|
||||
emit(child)
|
||||
wf(" %s --> %s", n.id, child.id)
|
||||
}
|
||||
}
|
||||
emit(root)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type incNode struct {
|
||||
id, label, class string
|
||||
// parseComponentRef parses a CI/CD component reference of the form
|
||||
// <host>/<project-path>/<component-name>@<version>.
|
||||
func parseComponentRef(ref string) (host, project, component, version string, err error) {
|
||||
atIdx := strings.LastIndex(ref, "@")
|
||||
if atIdx < 0 || atIdx == len(ref)-1 {
|
||||
err = fmt.Errorf("component reference %q must include a version", ref)
|
||||
return
|
||||
}
|
||||
version = ref[atIdx+1:]
|
||||
path := ref[:atIdx]
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 3 {
|
||||
err = fmt.Errorf("component reference %q must be <host>/<project>/<component>@<version>", ref)
|
||||
return
|
||||
}
|
||||
host = parts[0]
|
||||
component = parts[len(parts)-1]
|
||||
project = strings.Join(parts[1:len(parts)-1], "/")
|
||||
return
|
||||
}
|
||||
|
||||
func parseIncludeEntry(entry any, counter *int) []incNode {
|
||||
newID := func() string {
|
||||
*counter++
|
||||
return fmt.Sprintf("inc%d", *counter)
|
||||
// fetchComponentFile fetches a component's template YAML, trying the single-file
|
||||
// layout first and the directory layout as fallback.
|
||||
func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version string) ([]byte, error) {
|
||||
if data, err := cfg.FetchFile(project, "templates/"+component+".yml", version); err == nil {
|
||||
return data, nil
|
||||
}
|
||||
switch v := entry.(type) {
|
||||
case string:
|
||||
return []incNode{{id: newID(), label: mermaidLabel(v), class: "local"}}
|
||||
case map[string]any:
|
||||
return parseIncludeMap(v, newID)
|
||||
data, err := cfg.FetchFile(project, "templates/"+component+"/template.yml", version)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("component %s/%s@%s not found", project, component, version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseIncludeMap(m map[string]any, newID func() string) []incNode {
|
||||
if comp, ok := m["component"].(string); ok {
|
||||
return []incNode{{
|
||||
id: newID(),
|
||||
label: "component:<br>" + mermaidLabel(comp),
|
||||
class: "component",
|
||||
}}
|
||||
}
|
||||
if proj, ok := m["project"].(string); ok {
|
||||
ref, _ := m["ref"].(string)
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
files := includeFileList(m["file"])
|
||||
if len(files) == 0 {
|
||||
return []incNode{{
|
||||
id: newID(),
|
||||
label: fmt.Sprintf("project: %s @ %s", mermaidLabel(proj), ref),
|
||||
class: "project",
|
||||
}}
|
||||
}
|
||||
var nodes []incNode
|
||||
for _, f := range files {
|
||||
nodes = append(nodes, incNode{
|
||||
id: newID(),
|
||||
label: fmt.Sprintf("project: %s<br>%s @ %s", mermaidLabel(proj), mermaidLabel(f), ref),
|
||||
class: "project",
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
if local, ok := m["local"].(string); ok {
|
||||
return []incNode{{id: newID(), label: "local: " + mermaidLabel(local), class: "local"}}
|
||||
}
|
||||
if remote, ok := m["remote"].(string); ok {
|
||||
return []incNode{{id: newID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}}
|
||||
}
|
||||
if tmpl, ok := m["template"].(string); ok {
|
||||
return []incNode{{id: newID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
|
||||
}
|
||||
return nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func includeFileList(v any) []string {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/cicontext"
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// Tree returns a tree-command-style text representation of pipeline jobs
|
||||
// grouped by stage.
|
||||
//
|
||||
// Hidden template jobs (names starting with ".") are excluded.
|
||||
// When ctx is non-nil and non-empty, each job is annotated with its evaluated
|
||||
// state ([skipped] or [manual]); active jobs carry no annotation.
|
||||
// Without a context, job-type annotations ([manual], [delayed], [trigger]) are
|
||||
// shown instead.
|
||||
func Tree(p *model.Pipeline, ctx *cicontext.Context) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Collect visible jobs.
|
||||
var visible []string
|
||||
for name := range p.Jobs {
|
||||
if !strings.HasPrefix(name, ".") {
|
||||
visible = append(visible, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(visible)
|
||||
|
||||
// Group by stage.
|
||||
byStage := make(map[string][]string)
|
||||
for _, name := range visible {
|
||||
stage := p.Jobs[name].Stage
|
||||
if stage == "" {
|
||||
stage = "test"
|
||||
}
|
||||
byStage[stage] = append(byStage[stage], name)
|
||||
}
|
||||
|
||||
// Build ordered stage list: declared first, then extras.
|
||||
seen := make(map[string]bool)
|
||||
var stages []string
|
||||
for _, s := range p.Stages {
|
||||
if len(byStage[s]) > 0 && !seen[s] {
|
||||
stages = append(stages, s)
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
for _, name := range visible {
|
||||
stage := p.Jobs[name].Stage
|
||||
if stage == "" {
|
||||
stage = "test"
|
||||
}
|
||||
if !seen[stage] {
|
||||
stages = append(stages, stage)
|
||||
seen[stage] = true
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("pipeline\n")
|
||||
|
||||
for si, stage := range stages {
|
||||
lastStage := si == len(stages)-1
|
||||
stagePrefix, childPrefix := branchChars(lastStage)
|
||||
|
||||
sb.WriteString(stagePrefix + stage + "\n")
|
||||
|
||||
jobs := byStage[stage]
|
||||
sort.Strings(jobs)
|
||||
for ji, name := range jobs {
|
||||
lastJob := ji == len(jobs)-1
|
||||
jobBranch, _ := branchChars(lastJob)
|
||||
sb.WriteString(childPrefix + jobBranch + jobLabel(p.Jobs[name], name, ctx) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func branchChars(last bool) (branch, continuation string) {
|
||||
if last {
|
||||
return "└── ", " "
|
||||
}
|
||||
return "├── ", "│ "
|
||||
}
|
||||
|
||||
func jobLabel(job model.Job, name string, ctx *cicontext.Context) string {
|
||||
if ctx != nil && !ctx.IsEmpty() {
|
||||
switch cicontext.EvalJob(job, ctx) {
|
||||
case cicontext.JobSkipped:
|
||||
return name + " [skipped]"
|
||||
case cicontext.JobManual:
|
||||
return name + " [manual]"
|
||||
}
|
||||
return name
|
||||
}
|
||||
// No context: annotate by job type.
|
||||
var tags []string
|
||||
if job.When == "manual" {
|
||||
tags = append(tags, "manual")
|
||||
}
|
||||
if job.When == "delayed" {
|
||||
tags = append(tags, "delayed")
|
||||
}
|
||||
if job.Trigger != nil {
|
||||
tags = append(tags, "trigger")
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return name
|
||||
}
|
||||
return name + " [" + strings.Join(tags, ", ") + "]"
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func checkTrigger(name string, job model.Job) []Finding {
|
||||
return nil
|
||||
}
|
||||
var findings []Finding
|
||||
if len(job.Script) > 0 {
|
||||
if scriptNonEmpty(job.Script) {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
|
||||
@@ -88,7 +88,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
||||
|
||||
// After extends resolution, a job with no script/run is an error.
|
||||
// Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs.
|
||||
hasScript := len(job.Script) > 0 || job.Run != nil
|
||||
hasScript := scriptNonEmpty(job.Script) || job.Run != nil
|
||||
if !isTemplate && !isTrigger && job.Pages == nil && !hasScript {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
@@ -139,3 +139,15 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// scriptNonEmpty reports whether a script/before_script/after_script field
|
||||
// (which may be a []any list or a plain string) is non-empty.
|
||||
func scriptNonEmpty(v any) bool {
|
||||
switch s := v.(type) {
|
||||
case []any:
|
||||
return len(s) > 0
|
||||
case string:
|
||||
return s != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package linter_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/fetcher"
|
||||
"git.k3nny.fr/glint/internal/linter"
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
"git.k3nny.fr/glint/internal/resolver"
|
||||
)
|
||||
|
||||
// TestSambaCI verifies that the Samba project's .gitlab-ci.yml (a real-world
|
||||
// pipeline that is valid on GitLab) produces no Error findings.
|
||||
// These files exercise local include resolution and multi-level extends chains.
|
||||
func TestSambaCI(t *testing.T) {
|
||||
entryPoint := "../../samba-testdata/.gitlab-ci.yml"
|
||||
|
||||
p, err := model.Parse(entryPoint)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
rootDir := filepath.Dir(filepath.Clean(entryPoint))
|
||||
incWarnings, _ := resolver.ResolveIncludes(p, fetcher.GitLabConfig{}, rootDir)
|
||||
for _, w := range incWarnings {
|
||||
t.Logf("include warning: %s", w)
|
||||
}
|
||||
|
||||
extWarnings, err := resolver.Resolve(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
for _, w := range extWarnings {
|
||||
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
|
||||
}
|
||||
|
||||
findings := linter.Lint(p)
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Severity == linter.Error {
|
||||
t.Errorf("unexpected error finding on valid Samba CI: %s", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSambaCIEntryFiles verifies all of the Samba entry-point files
|
||||
// (files that can each act as the top-level CI file) lint without errors.
|
||||
func TestSambaCIEntryFiles(t *testing.T) {
|
||||
entryPoints := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"default", "../../samba-testdata/.gitlab-ci.yml"},
|
||||
{"coverage", "../../samba-testdata/.gitlab-ci-coverage.yml"},
|
||||
{"private", "../../samba-testdata/.gitlab-ci-private.yml"},
|
||||
}
|
||||
|
||||
for _, tc := range entryPoints {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, err := model.Parse(tc.path)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
rootDir := filepath.Dir(filepath.Clean(tc.path))
|
||||
incWarnings, _ := resolver.ResolveIncludes(p, fetcher.GitLabConfig{}, rootDir)
|
||||
for _, w := range incWarnings {
|
||||
t.Logf("include warning: %s", w)
|
||||
}
|
||||
|
||||
extWarnings, err := resolver.Resolve(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
for _, w := range extWarnings {
|
||||
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
|
||||
}
|
||||
|
||||
findings := linter.Lint(p)
|
||||
for _, f := range findings {
|
||||
if f.Severity == linter.Error {
|
||||
t.Errorf("unexpected error finding: %s", f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,10 @@ type Workflow struct {
|
||||
type Job struct {
|
||||
Name string // set by parser, not from YAML
|
||||
Stage string `yaml:"stage"`
|
||||
Script []string `yaml:"script"`
|
||||
Run any `yaml:"run"` // alternative to script (CI steps)
|
||||
BeforeScript []string `yaml:"before_script"`
|
||||
AfterScript []string `yaml:"after_script"`
|
||||
Script any `yaml:"script"` // []string or string (block scalar)
|
||||
Run any `yaml:"run"` // alternative to script (CI steps)
|
||||
BeforeScript any `yaml:"before_script"` // []string or string
|
||||
AfterScript any `yaml:"after_script"` // []string or string
|
||||
Image any `yaml:"image"`
|
||||
Services []any `yaml:"services"`
|
||||
Variables map[string]string `yaml:"variables"`
|
||||
|
||||
@@ -10,33 +10,45 @@ import (
|
||||
// Resolve resolves all extends: references in p.Jobs in place.
|
||||
// Jobs are merged depth-first so that base definitions are resolved before
|
||||
// derived ones. Mutates p.Jobs with the fully merged Job structs.
|
||||
func Resolve(p *model.Pipeline) error {
|
||||
//
|
||||
// The first return value lists extends references whose base job could not be
|
||||
// found (e.g. it lives in a remote include that was not fetched). Those jobs
|
||||
// are left unmerged but still passed to the linter. A non-nil error is only
|
||||
// returned for unrecoverable situations such as circular dependencies.
|
||||
func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
|
||||
var extWarnings []ExtendWarning
|
||||
|
||||
// Build extends graph: jobName -> ordered list of base job names.
|
||||
extendsGraph := make(map[string][]string, len(p.Jobs))
|
||||
for name, job := range p.Jobs {
|
||||
bases, err := parseExtends(job.Extends)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %q: invalid extends: %w", name, err)
|
||||
return extWarnings, fmt.Errorf("job %q: invalid extends: %w", name, err)
|
||||
}
|
||||
if len(bases) == 0 {
|
||||
continue
|
||||
}
|
||||
skip := false
|
||||
for _, base := range bases {
|
||||
if _, ok := p.RawJobs[base]; !ok {
|
||||
return fmt.Errorf("job %q extends unknown job %q", name, base)
|
||||
extWarnings = append(extWarnings, ExtendWarning{Job: name, Base: base})
|
||||
skip = true
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
continue // leave this job unmerged; still linted with its own fields
|
||||
}
|
||||
extendsGraph[name] = bases
|
||||
}
|
||||
|
||||
if len(extendsGraph) == 0 {
|
||||
return nil
|
||||
return extWarnings, nil
|
||||
}
|
||||
|
||||
// Topological sort — bases must be resolved before derived jobs.
|
||||
order, err := topoSort(extendsGraph)
|
||||
if err != nil {
|
||||
return err
|
||||
return extWarnings, err
|
||||
}
|
||||
|
||||
// resolved holds the final merged raw map for each processed job.
|
||||
@@ -61,17 +73,17 @@ func Resolve(p *model.Pipeline) error {
|
||||
// Re-decode the merged map into a Job struct.
|
||||
data, err := yaml.Marshal(merged)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %q: re-encoding merged definition: %w", name, err)
|
||||
return extWarnings, fmt.Errorf("job %q: re-encoding merged definition: %w", name, err)
|
||||
}
|
||||
var j model.Job
|
||||
if err := yaml.Unmarshal(data, &j); err != nil {
|
||||
return fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
|
||||
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
|
||||
}
|
||||
j.Name = name
|
||||
p.Jobs[name] = j
|
||||
}
|
||||
|
||||
return nil
|
||||
return extWarnings, nil
|
||||
}
|
||||
|
||||
// parseExtends normalises the extends field (string or []any) into []string.
|
||||
|
||||
+105
-42
@@ -2,18 +2,18 @@ package resolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/fetcher"
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// IncludeWarning describes a remote include entry that could not be resolved.
|
||||
// IncludeWarning describes an include entry that could not be resolved.
|
||||
// These are surfaced to the user as [WARNING] lines before the lint findings.
|
||||
type IncludeWarning struct {
|
||||
// Label is a short human-readable identifier shown in the warning message,
|
||||
// e.g. "project my-group/templates:/ci.yml@main" or
|
||||
// "component gitlab.com/components/golang/build@v1.0".
|
||||
// Label is a short human-readable identifier shown in the warning message.
|
||||
Label string
|
||||
Err error // nil when Skipped is true
|
||||
Skipped bool // no token available — skipped without attempting a network call
|
||||
@@ -30,46 +30,114 @@ func (w IncludeWarning) String() string {
|
||||
w.Label, w.Err)
|
||||
}
|
||||
|
||||
// ExtendWarning describes an extends: reference whose base job could not be
|
||||
// found. This typically means the base is in a remote include that was not
|
||||
// fetched (e.g. no token). The job's extends chain is skipped; the job itself
|
||||
// is still linted with whatever fields it directly defines.
|
||||
type ExtendWarning struct {
|
||||
Job string // job that declares the extends
|
||||
Base string // the unknown base job name
|
||||
}
|
||||
|
||||
// ResolveIncludes processes the pipeline's include: block.
|
||||
//
|
||||
// Project includes (include: project: ...) require authentication and are
|
||||
// skipped with a warning when no token is configured.
|
||||
// rootDir is the repository root directory used to resolve local: includes.
|
||||
// In practice this is the directory of the top-level pipeline file being linted.
|
||||
//
|
||||
// Component includes (include: component: ...) attempt the fetch
|
||||
// unauthenticated first, so public CI/CD catalog components work without a
|
||||
// token. A warning is emitted when the fetch fails.
|
||||
// Local includes are read from disk and merged recursively.
|
||||
// Project includes require authentication and are skipped with a warning when
|
||||
// no token is configured. Component includes are attempted unauthenticated.
|
||||
// Remote and template includes are silently skipped (resolved by GitLab at runtime).
|
||||
//
|
||||
// Non-resolvable include forms (local, remote, template) are silently skipped.
|
||||
func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig) []IncludeWarning {
|
||||
// The second return value carries extends warnings discovered while recursively
|
||||
// processing included files (forwarded from resolver.Resolve calls).
|
||||
func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig, rootDir string) ([]IncludeWarning, []ExtendWarning) {
|
||||
visited := map[string]bool{}
|
||||
return resolveIncludes(p, p.Include, cfg, rootDir, visited)
|
||||
}
|
||||
|
||||
// resolveIncludes is the recursive core of ResolveIncludes.
|
||||
func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
|
||||
var warnings []IncludeWarning
|
||||
for _, inc := range p.Include {
|
||||
var extWarnings []ExtendWarning
|
||||
|
||||
for _, inc := range includes {
|
||||
entry, ok := normaliseInclude(inc)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if project, _ := entry["project"].(string); project != "" {
|
||||
warnings = append(warnings, resolveProjectInclude(p, entry, project, cfg)...)
|
||||
w, ew := resolveProjectInclude(p, entry, project, cfg, rootDir, visited)
|
||||
warnings = append(warnings, w...)
|
||||
extWarnings = append(extWarnings, ew...)
|
||||
continue
|
||||
}
|
||||
|
||||
if compRef, _ := entry["component"].(string); compRef != "" {
|
||||
if w, ok := resolveComponentInclude(p, compRef, cfg); ok {
|
||||
w, ew, hadErr := resolveComponentInclude(p, compRef, cfg, rootDir, visited)
|
||||
if hadErr {
|
||||
warnings = append(warnings, w)
|
||||
}
|
||||
extWarnings = append(extWarnings, ew...)
|
||||
continue
|
||||
}
|
||||
|
||||
// local, remote, template — resolved by GitLab at runtime, skip silently.
|
||||
if local, _ := entry["local"].(string); local != "" {
|
||||
w, ew := resolveLocalInclude(p, local, cfg, rootDir, visited)
|
||||
warnings = append(warnings, w...)
|
||||
extWarnings = append(extWarnings, ew...)
|
||||
continue
|
||||
}
|
||||
|
||||
// remote, template — resolved by GitLab at runtime, skip silently.
|
||||
}
|
||||
return warnings
|
||||
return warnings, extWarnings
|
||||
}
|
||||
|
||||
// resolveLocalInclude reads a local file from disk (paths are always relative
|
||||
// to the repository root, with or without a leading slash), recursively
|
||||
// resolves its own includes, and merges it into p.
|
||||
func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
|
||||
relPath := strings.TrimPrefix(rawPath, "/")
|
||||
absPath := filepath.Join(rootDir, relPath)
|
||||
label := "local " + rawPath
|
||||
|
||||
if visited[absPath] {
|
||||
return nil, nil
|
||||
}
|
||||
visited[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return []IncludeWarning{{Label: label, Err: err}}, nil
|
||||
}
|
||||
|
||||
included, err := model.ParseBytes(data)
|
||||
if err != nil {
|
||||
return []IncludeWarning{{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)}}, nil
|
||||
}
|
||||
|
||||
// Recursively resolve the included file's own includes first, merging
|
||||
// everything into `included` before we merge it into the parent `p`.
|
||||
var warnings []IncludeWarning
|
||||
var extWarnings []ExtendWarning
|
||||
if len(included.Include) > 0 {
|
||||
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
|
||||
warnings = append(warnings, w...)
|
||||
extWarnings = append(extWarnings, ew...)
|
||||
}
|
||||
|
||||
mergeIncluded(p, included)
|
||||
return warnings, extWarnings
|
||||
}
|
||||
|
||||
// resolveProjectInclude fetches all files listed under a single project: entry
|
||||
// and merges them into p.
|
||||
func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig) []IncludeWarning {
|
||||
func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
|
||||
ref, _ := entry["ref"].(string)
|
||||
var warnings []IncludeWarning
|
||||
var extWarnings []ExtendWarning
|
||||
|
||||
for _, filePath := range includeFiles(entry) {
|
||||
label := fmt.Sprintf("project %s:%s", project, filePath)
|
||||
@@ -94,56 +162,58 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
|
||||
continue
|
||||
}
|
||||
|
||||
if len(included.Include) > 0 {
|
||||
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
|
||||
warnings = append(warnings, w...)
|
||||
extWarnings = append(extWarnings, ew...)
|
||||
}
|
||||
|
||||
mergeIncluded(p, included)
|
||||
}
|
||||
return warnings
|
||||
return warnings, extWarnings
|
||||
}
|
||||
|
||||
// resolveComponentInclude fetches a CI/CD catalog component and merges it into p.
|
||||
// Returns (warning, true) if something went wrong; (zero, false) on success.
|
||||
func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabConfig) (IncludeWarning, bool) {
|
||||
// Returns (warning, extWarnings, true) if something went wrong; (zero, nil, false) on success.
|
||||
func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) (IncludeWarning, []ExtendWarning, bool) {
|
||||
label := "component " + ref
|
||||
|
||||
// Variable interpolation (e.g. $CI_SERVER_FQDN) cannot be resolved locally.
|
||||
if strings.ContainsRune(ref, '$') {
|
||||
return IncludeWarning{
|
||||
Label: label,
|
||||
Err: fmt.Errorf("component reference contains a CI variable that cannot be resolved at lint time"),
|
||||
}, true
|
||||
}, nil, true
|
||||
}
|
||||
|
||||
host, project, component, version, err := parseComponentRef(ref)
|
||||
if err != nil {
|
||||
return IncludeWarning{Label: label, Err: err}, true
|
||||
return IncludeWarning{Label: label, Err: err}, nil, true
|
||||
}
|
||||
|
||||
hostCfg := cfg.ForHost(host)
|
||||
data, err := fetchComponentFile(hostCfg, project, component, version)
|
||||
if err != nil {
|
||||
return IncludeWarning{Label: label, Err: err}, true
|
||||
return IncludeWarning{Label: label, Err: err}, nil, true
|
||||
}
|
||||
|
||||
included, err := model.ParseBytes(data)
|
||||
if err != nil {
|
||||
return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, true
|
||||
return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true
|
||||
}
|
||||
|
||||
var extWarnings []ExtendWarning
|
||||
if len(included.Include) > 0 {
|
||||
_, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
|
||||
extWarnings = append(extWarnings, ew...)
|
||||
}
|
||||
|
||||
mergeIncluded(p, included)
|
||||
return IncludeWarning{}, false
|
||||
return IncludeWarning{}, extWarnings, false
|
||||
}
|
||||
|
||||
// parseComponentRef parses a CI/CD component reference of the form:
|
||||
//
|
||||
// <host>/<project-path>/<component-name>@<version>
|
||||
//
|
||||
// The host is the GitLab instance FQDN. The last path segment before @ is the
|
||||
// component name; everything between host and component name is the project path.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// gitlab.com/components/golang/build@v1.0.0
|
||||
// gitlab.example.com/my-org/ci-templates/lint@main
|
||||
// gitlab.com/components/secret-detection/secret-detection@~latest
|
||||
func parseComponentRef(ref string) (host, project, component, version string, err error) {
|
||||
atIdx := strings.LastIndex(ref, "@")
|
||||
if atIdx < 0 || atIdx == len(ref)-1 {
|
||||
@@ -154,7 +224,6 @@ func parseComponentRef(ref string) (host, project, component, version string, er
|
||||
path := ref[:atIdx]
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
// Minimum: host + at least one project segment + component = 3 parts.
|
||||
if len(parts) < 3 {
|
||||
err = fmt.Errorf("component reference %q must be <host>/<project>/<component>@<version>", ref)
|
||||
return
|
||||
@@ -167,9 +236,6 @@ func parseComponentRef(ref string) (host, project, component, version string, er
|
||||
}
|
||||
|
||||
// fetchComponentFile fetches a component's template YAML from a GitLab project.
|
||||
// GitLab supports two layouts; both are attempted:
|
||||
// - templates/<component>.yml (single-file component)
|
||||
// - templates/<component>/template.yml (directory component)
|
||||
func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version string) ([]byte, error) {
|
||||
primary := "templates/" + component + ".yml"
|
||||
data, err := cfg.FetchFile(project, primary, version)
|
||||
@@ -180,14 +246,12 @@ func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version st
|
||||
fallback := "templates/" + component + "/template.yml"
|
||||
data2, err2 := cfg.FetchFile(project, fallback, version)
|
||||
if err2 != nil {
|
||||
// Return the primary error — it refers to the canonical path.
|
||||
return nil, fmt.Errorf("%v (also tried %s: %v)", err, fallback, err2)
|
||||
}
|
||||
return data2, nil
|
||||
}
|
||||
|
||||
// normaliseInclude converts a raw include: list element to a string-keyed map.
|
||||
// An include: value can be a plain string (shorthand local) or a map.
|
||||
func normaliseInclude(raw any) (map[string]any, bool) {
|
||||
switch v := raw.(type) {
|
||||
case map[string]any:
|
||||
@@ -199,7 +263,6 @@ func normaliseInclude(raw any) (map[string]any, bool) {
|
||||
}
|
||||
|
||||
// includeFiles returns the list of file paths from an include entry.
|
||||
// The file: key may be a single string or a list of strings.
|
||||
func includeFiles(entry map[string]any) []string {
|
||||
raw, ok := entry["file"]
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user