Files
glint/internal/resolver/extends.go
T
k3nny 1615655c00 feat(cli): --no-warn, new exit codes, glint render, and graph --no-skipped
Exit codes (breaking change from exit 1):
- 0  clean (no findings)
- 2  one or more errors
- 10 one or more warnings, no errors
Errors take precedence over warnings.

glint check --no-warn:
  Discard warning findings before output and exit-code calculation.
  Mixed pipelines (errors + warnings) still exit 2 but only errors print.
  Warnings-only pipelines exit 0 with "OK" when --no-warn is set.

glint render <PIPELINE>:
  New subcommand. Resolves all include: and extends: chains, then writes
  a single flat .gitlab-ci.yml (default: rendered.gitlab-ci.yml, or
  stdout with --output -). Strips consumed keys (include:, extends:).
  Template jobs (.) are retained. Flags: --output, --token, --gitlab-url,
  --cache-dir, --offline, --proxy (all with .glint.yml fallback).
  To support render, Resolve() now writes the merged raw map back to
  p.RawJobs[name] and also preserves j.Column from the original job.

glint graph --no-skipped:
  Removes jobs that evaluate to JobSkipped in the given context before
  any graph function sees the pipeline. Works for tree, pipeline (SVG,
  HTML, Mermaid), includes, and all modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:27:12 +02:00

181 lines
4.8 KiB
Go

package resolver
import (
"fmt"
"git.k3nny.fr/glint/internal/model"
"gopkg.in/yaml.v3"
)
// 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.
//
// 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 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 {
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 extWarnings, nil
}
// Topological sort — bases must be resolved before derived jobs.
order, err := topoSort(extendsGraph)
if err != nil {
return extWarnings, err
}
// resolved holds the final merged raw map for each processed job.
resolved := make(map[string]map[string]any, len(order))
for _, name := range order {
bases := extendsGraph[name]
// Merge bases left-to-right (later bases override earlier ones),
// then overlay the job's own definition on top.
merged := make(map[string]any)
for _, baseName := range bases {
baseMap := resolved[baseName]
if baseMap == nil {
baseMap = p.RawJobs[baseName]
}
merged = deepMerge(merged, baseMap)
}
merged = deepMerge(merged, p.RawJobs[name])
resolved[name] = merged
// Re-decode the merged map into a Job struct.
data, err := yaml.Marshal(merged)
if err != nil {
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 extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
}
j.Name = name
// Preserve source location — these fields are not part of the YAML map
// and are lost during the encode/decode round-trip.
orig := p.Jobs[name]
j.File = orig.File
j.Line = orig.Line
j.Column = orig.Column
p.Jobs[name] = j
// Write merged raw map back so p.RawJobs always reflects post-extends state.
p.RawJobs[name] = merged
}
return extWarnings, nil
}
// parseExtends normalises the extends field (string or []any) into []string.
func parseExtends(extends any) ([]string, error) {
if extends == nil {
return nil, nil
}
switch v := extends.(type) {
case string:
return []string{v}, nil
case []any:
names := make([]string, 0, len(v))
for _, item := range v {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("expected string item, got %T", item)
}
names = append(names, s)
}
return names, nil
default:
return nil, fmt.Errorf("unexpected type %T", extends)
}
}
// topoSort returns the keys of graph in dependency order (bases first).
// Returns an error if a cycle is detected.
func topoSort(graph map[string][]string) ([]string, error) {
const (
unvisited = 0
visiting = 1
visited = 2
)
state := make(map[string]int, len(graph))
order := make([]string, 0, len(graph))
var visit func(name string) error
visit = func(name string) error {
switch state[name] {
case visited:
return nil
case visiting:
return fmt.Errorf("cycle in extends involving job %q", name)
}
state[name] = visiting
for _, dep := range graph[name] {
if _, inGraph := graph[dep]; inGraph {
if err := visit(dep); err != nil {
return err
}
}
}
state[name] = visited
order = append(order, name)
return nil
}
for name := range graph {
if err := visit(name); err != nil {
return nil, err
}
}
return order, nil
}
// deepMerge returns a new map that is override merged onto base.
// Maps are merged recursively; all other values are replaced by override.
func deepMerge(base, override map[string]any) map[string]any {
result := make(map[string]any, len(base)+len(override))
for k, v := range base {
result[k] = v
}
for k, v := range override {
if baseVal, exists := result[k]; exists {
if baseMap, ok := baseVal.(map[string]any); ok {
if overMap, ok := v.(map[string]any); ok {
result[k] = deepMerge(baseMap, overMap)
continue
}
}
}
result[k] = v
}
return result
}