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:
+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