feat(cli): output formats, GL034-GL041 lint rules, include inputs and cache
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m9s

Bundles three patch releases (v0.2.16–v0.2.18):

v0.2.18 — output formats (--format flag on glint check):
- json: stable JSON report (schema_version: 1, findings array, summary)
- sarif: SARIF 2.1.0 for GitHub Code Scanning / GitLab SAST
- junit: JUnit XML for CI test-report artifacts (artifacts:reports:junit)
- github: GitHub Actions ::error:: / ::warning:: annotation lines
- Unknown --format value exits 2 with a helpful error message
- Summary line routed to stderr in structured formats; context suppressed

v0.2.17 — include resolution improvements:
- Recursive include depth capped at 100 (matches GitLab's own limit)
- project: and component: includes tracked in visited set (cycle detection)
- $[[ inputs.KEY ]] / $[[ inputs.KEY | default(…) ]] substituted from with:
- --cache-dir: persist fetched remote templates to disk (SHA-256 keyed)
- --offline: serve from cache only; defaults to ~/.cache/glint

v0.2.16 — new lint rules (GL034–GL041):
- GL034: services map form requires name; alias must be valid DNS label
- GL035: rules:changes / rules:exists absolute path detection
- GL036: timeout format validation (job-level + default.timeout)
- GL037: id_tokens entries must have an aud key
- GL038: secrets entries must declare a provider (vault / gcp / azure)
- GL039: pages: keyword + artifacts.paths consistency
- GL040: duplicate stage names in stages: list
- GL041: cache.key.files must be exact paths, not globs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 10:09:16 +02:00
parent 54b5850835
commit f5f8546bcf
17 changed files with 1623 additions and 66 deletions
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
// --- JSON ---
type jsonReport struct {
SchemaVersion int `json:"schema_version"`
GlintVersion string `json:"glint_version"`
Pipeline string `json:"pipeline"`
Findings []jsonFinding `json:"findings"`
Summary jsonSummary `json:"summary"`
}
type jsonFinding struct {
Rule string `json:"rule,omitempty"`
Severity string `json:"severity"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Job string `json:"job,omitempty"`
Message string `json:"message"`
}
type jsonSummary struct {
Total int `json:"total"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}
func writeJSON(w io.Writer, findings []linter.Finding, pipeline string) {
errs, warns := countSeverities(findings)
jf := make([]jsonFinding, 0, len(findings))
for _, f := range findings {
jf = append(jf, jsonFinding{
Rule: f.Rule,
Severity: strings.ToLower(string(f.Severity)),
File: f.File,
Line: f.Line,
Job: f.Job,
Message: f.Message,
})
}
report := jsonReport{
SchemaVersion: 1,
GlintVersion: version,
Pipeline: pipeline,
Findings: jf,
Summary: jsonSummary{Total: len(findings), Errors: errs, Warnings: warns},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(report)
}
// --- SARIF 2.1.0 ---
type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}
type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}
type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
Version string `json:"version"`
InformationURI string `json:"informationUri"`
Rules []sarifRule `json:"rules"`
}
type sarifRule struct {
ID string `json:"id"`
ShortDescription sarifMessage `json:"shortDescription"`
HelpURI string `json:"helpUri,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}
type sarifResult struct {
RuleID string `json:"ruleId,omitempty"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations,omitempty"`
}
type sarifLocation struct {
PhysicalLocation sarifPhysLoc `json:"physicalLocation"`
}
type sarifPhysLoc struct {
ArtifactLocation sarifArtifact `json:"artifactLocation"`
Region *sarifRegion `json:"region,omitempty"`
}
type sarifArtifact struct {
URI string `json:"uri"`
URIBaseID string `json:"uriBaseId,omitempty"`
}
type sarifRegion struct {
StartLine int `json:"startLine"`
}
func writeSARIF(w io.Writer, findings []linter.Finding, _ string) {
// Collect unique rule IDs (preserving first-seen order).
seenRules := map[string]bool{}
var rules []sarifRule
for _, f := range findings {
if f.Rule != "" && !seenRules[f.Rule] {
seenRules[f.Rule] = true
rules = append(rules, sarifRule{
ID: f.Rule,
ShortDescription: sarifMessage{Text: "glint rule " + f.Rule},
HelpURI: "https://git.k3nny.fr/glint",
})
}
}
if rules == nil {
rules = []sarifRule{}
}
var results []sarifResult
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
result := sarifResult{
RuleID: f.Rule,
Level: level,
Message: sarifMessage{Text: msg},
}
if f.File != "" {
loc := sarifLocation{
PhysicalLocation: sarifPhysLoc{
ArtifactLocation: sarifArtifact{URI: f.File, URIBaseID: "%SRCROOT%"},
},
}
if f.Line > 0 {
loc.PhysicalLocation.Region = &sarifRegion{StartLine: f.Line}
}
result.Locations = []sarifLocation{loc}
}
results = append(results, result)
}
if results == nil {
results = []sarifResult{}
}
log := sarifLog{
Schema: "https://json.schemastore.org/sarif-2.1.0.json",
Version: "2.1.0",
Runs: []sarifRun{{
Tool: sarifTool{Driver: sarifDriver{
Name: "glint",
Version: strings.TrimPrefix(version, "v"),
InformationURI: "https://git.k3nny.fr/glint",
Rules: rules,
}},
Results: results,
}},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(log)
}
// --- JUnit XML ---
type junitTestsuites struct {
XMLName xml.Name `xml:"testsuites"`
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Suites []junitTestsuite `xml:"testsuite"`
}
type junitTestsuite struct {
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Cases []junitTestcase `xml:"testcase"`
}
type junitTestcase struct {
Name string `xml:"name,attr"`
Classname string `xml:"classname,attr"`
Failure *junitFailure `xml:"failure,omitempty"`
}
type junitFailure struct {
Message string `xml:"message,attr"`
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
func writeJUnit(w io.Writer, findings []linter.Finding, pipeline string) {
_, fails := countSeverities(findings)
_ = fails // re-derive below to count both errors and warnings as failures
var cases []junitTestcase
if len(findings) == 0 {
cases = []junitTestcase{{Name: "no issues found", Classname: pipeline}}
} else {
for _, f := range findings {
name := f.Rule
if name == "" {
name = "lint"
}
classname := f.File
if classname == "" {
classname = pipeline
}
if f.Job != "" {
classname += "#" + f.Job
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
cases = append(cases, junitTestcase{
Name: name,
Classname: classname,
Failure: &junitFailure{
Message: msg,
Type: strings.ToLower(string(f.Severity)),
Body: f.String(),
},
})
}
}
suite := junitTestsuite{
Name: pipeline,
Tests: len(cases),
Failures: len(findings), // all findings are failures
Time: "0",
Cases: cases,
}
suites := junitTestsuites{
Name: "glint",
Tests: len(cases),
Failures: len(findings),
Time: "0",
Suites: []junitTestsuite{suite},
}
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>`)
fmt.Fprintln(w)
enc := xml.NewEncoder(w)
enc.Indent("", " ")
_ = enc.Encode(suites)
_ = enc.Flush()
fmt.Fprintln(w)
}
// --- GitHub Actions annotations ---
// writeGitHub emits GitHub Actions workflow command annotation lines.
// Each finding becomes an ::error:: or ::warning:: line that GitHub CI
// renders as an inline comment on the relevant file in pull requests.
func writeGitHub(w io.Writer, findings []linter.Finding) {
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
// GitHub annotation messages must not contain raw newlines, percent signs,
// carriage returns, or colons in the parameter block.
msg = strings.ReplaceAll(msg, "%", "%25")
msg = strings.ReplaceAll(msg, "\r", "%0D")
msg = strings.ReplaceAll(msg, "\n", "%0A")
var params []string
if f.File != "" {
params = append(params, "file="+f.File)
if f.Line > 0 {
params = append(params, fmt.Sprintf("line=%d", f.Line))
}
}
if f.Rule != "" {
params = append(params, "title="+f.Rule)
}
paramStr := ""
if len(params) > 0 {
paramStr = " " + strings.Join(params, ",")
}
fmt.Fprintf(w, "::%s%s::%s\n", level, paramStr, msg)
}
}
// --- shared helpers ---
func countSeverities(findings []linter.Finding) (errors, warnings int) {
for _, f := range findings {
if f.Severity == linter.Error {
errors++
} else {
warnings++
}
}
return
}
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"strings"
"testing"
"git.k3nny.fr/glint/internal/linter"
)
var testFindings = []linter.Finding{
{
Severity: linter.Error,
Rule: "GL004",
Job: "deploy",
File: ".gitlab-ci.yml",
Line: 14,
Message: `stage "production" is not defined in 'stages'`,
},
{
Severity: linter.Warning,
Rule: "GL007",
Job: "old-job",
File: ".gitlab-ci.yml",
Line: 31,
Message: "'only'/'except' are deprecated; prefer 'rules'",
},
}
func TestWriteJSON(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeJSON(&buf, testFindings, ".gitlab-ci.yml")
var got jsonReport
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.SchemaVersion != 1 {
t.Errorf("schema_version = %d, want 1", got.SchemaVersion)
}
if len(got.Findings) != 2 {
t.Fatalf("got %d findings, want 2", len(got.Findings))
}
if got.Summary.Errors != 1 || got.Summary.Warnings != 1 {
t.Errorf("summary = %+v, want {2,1,1}", got.Summary)
}
if got.Findings[0].Rule != "GL004" || got.Findings[0].Severity != "error" {
t.Errorf("finding[0] = %+v", got.Findings[0])
}
if got.Findings[0].Job != "deploy" || got.Findings[0].Line != 14 {
t.Errorf("finding[0] job/line = %s/%d", got.Findings[0].Job, got.Findings[0].Line)
}
})
t.Run("empty", func(t *testing.T) {
var buf bytes.Buffer
writeJSON(&buf, nil, "ci.yml")
var got jsonReport
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Findings == nil {
t.Error("findings must be [] not null")
}
if got.Summary.Total != 0 {
t.Errorf("total = %d, want 0", got.Summary.Total)
}
})
}
func TestWriteSARIF(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeSARIF(&buf, testFindings, ".gitlab-ci.yml")
var got sarifLog
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Version != "2.1.0" {
t.Errorf("version = %q, want 2.1.0", got.Version)
}
if len(got.Runs) != 1 {
t.Fatalf("runs count = %d, want 1", len(got.Runs))
}
run := got.Runs[0]
if run.Tool.Driver.Name != "glint" {
t.Errorf("driver.name = %q", run.Tool.Driver.Name)
}
if len(run.Results) != 2 {
t.Fatalf("results count = %d, want 2", len(run.Results))
}
r0 := run.Results[0]
if r0.RuleID != "GL004" || r0.Level != "error" {
t.Errorf("result[0] ruleId/level = %s/%s", r0.RuleID, r0.Level)
}
if len(r0.Locations) != 1 {
t.Fatalf("result[0] locations count = %d, want 1", len(r0.Locations))
}
loc := r0.Locations[0].PhysicalLocation
if loc.ArtifactLocation.URI != ".gitlab-ci.yml" {
t.Errorf("artifactLocation.uri = %q", loc.ArtifactLocation.URI)
}
if loc.Region == nil || loc.Region.StartLine != 14 {
t.Errorf("region = %v", loc.Region)
}
// Message should include job name prefix.
if !strings.Contains(r0.Message.Text, `job "deploy"`) {
t.Errorf("message missing job prefix: %q", r0.Message.Text)
}
})
t.Run("empty", func(t *testing.T) {
var buf bytes.Buffer
writeSARIF(&buf, nil, "ci.yml")
var got sarifLog
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
run := got.Runs[0]
if run.Results == nil {
t.Error("results must be [] not null")
}
if run.Tool.Driver.Rules == nil {
t.Error("rules must be [] not null")
}
})
}
func TestWriteJUnit(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeJUnit(&buf, testFindings, ".gitlab-ci.yml")
if !strings.HasPrefix(buf.String(), "<?xml") {
t.Error("output does not start with XML declaration")
}
var got junitTestsuites
if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid XML: %v\noutput:\n%s", err, buf.String())
}
if got.Failures != 2 {
t.Errorf("failures = %d, want 2", got.Failures)
}
if len(got.Suites) != 1 {
t.Fatalf("suites count = %d, want 1", len(got.Suites))
}
suite := got.Suites[0]
if len(suite.Cases) != 2 {
t.Fatalf("test cases count = %d, want 2", len(suite.Cases))
}
if suite.Cases[0].Name != "GL004" {
t.Errorf("case[0].name = %q", suite.Cases[0].Name)
}
if suite.Cases[0].Failure == nil {
t.Error("case[0] has no failure element")
}
})
t.Run("empty_is_passing", func(t *testing.T) {
var buf bytes.Buffer
writeJUnit(&buf, nil, "ci.yml")
var got junitTestsuites
if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid XML: %v", err)
}
if got.Failures != 0 {
t.Errorf("failures = %d, want 0", got.Failures)
}
suite := got.Suites[0]
if len(suite.Cases) != 1 || suite.Cases[0].Failure != nil {
t.Error("expected exactly one passing testcase when no findings")
}
})
}
func TestWriteGitHub(t *testing.T) {
cases := []struct {
name string
findings []linter.Finding
wantLine string
}{
{
name: "error with file and line",
findings: []linter.Finding{testFindings[0]},
wantLine: "::error file=.gitlab-ci.yml,line=14,title=GL004::job \"deploy\": stage \"production\" is not defined in 'stages'",
},
{
name: "warning",
findings: []linter.Finding{testFindings[1]},
wantLine: "::warning file=.gitlab-ci.yml,line=31,title=GL007::job \"old-job\": 'only'/'except' are deprecated; prefer 'rules'",
},
{
name: "no file or line",
findings: []linter.Finding{{
Severity: linter.Error,
Rule: "GL001",
Message: "no stages defined",
}},
wantLine: "::error title=GL001::no stages defined",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
writeGitHub(&buf, tc.findings)
got := strings.TrimRight(buf.String(), "\n")
if got != tc.wantLine {
t.Errorf("\ngot: %s\nwant: %s", got, tc.wantLine)
}
})
}
}
func TestCountSeverities(t *testing.T) {
errs, warns := countSeverities(testFindings)
if errs != 1 || warns != 1 {
t.Errorf("countSeverities = (%d, %d), want (1, 1)", errs, warns)
}
}
+85 -17
View File
@@ -19,6 +19,18 @@ import (
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
// defaultCacheDir returns the platform-default glint cache directory:
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
func defaultCacheDir() string {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return filepath.Join(xdg, "glint")
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".cache", "glint")
}
return ""
}
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND>
@@ -68,6 +80,9 @@ func cmdCheck(args []string) {
fs := flag.NewFlagSet("glint check", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
format := fs.String("format", "text", "output format: text, json, sarif, junit, github")
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
@@ -87,6 +102,10 @@ Arguments:
<PIPELINE> Path to the .gitlab-ci.yml file to lint
Options:
--format <FORMAT>
Output format for findings.
[default: text] [possible values: text, json, sarif, junit, github]
--token <TOKEN>
GitLab personal access token. Required to fetch project: includes;
component: includes are attempted unauthenticated.
@@ -96,6 +115,17 @@ Options:
GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache fetched remote templates (project: and component: includes) in
DIR. The directory is created on first use. Subsequent runs read from
cache first, avoiding repeated network calls.
--offline
Do not make any network calls. All remote includes must already be
present in --cache-dir; missing entries emit a warning (same as
having no token). Implies the default cache dir (~/.cache/glint) when
--cache-dir is not set.
--branch <NAME>
Simulate a branch push. Populates: CI_COMMIT_BRANCH,
CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push.
@@ -127,6 +157,10 @@ always evaluated.
Examples:
glint check .gitlab-ci.yml
glint check --format json .gitlab-ci.yml
glint check --format sarif .gitlab-ci.yml | upload-to-github-code-scanning
glint check --format junit .gitlab-ci.yml > junit.xml
glint check --format github .gitlab-ci.yml
glint check --branch develop .gitlab-ci.yml
glint check --tag v1.0.0 .gitlab-ci.yml
glint check --source merge_request_event .gitlab-ci.yml
@@ -134,6 +168,8 @@ Examples:
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
@@ -144,13 +180,27 @@ Examples:
*source = "push"
}
validFormats := map[string]bool{
"text": true, "json": true, "sarif": true, "junit": true, "github": true,
}
if !validFormats[*format] {
fmt.Fprintf(os.Stderr, "glint: unknown format %q; valid: text, json, sarif, junit, github\n", *format)
os.Exit(2)
}
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
}
path := fs.Arg(0)
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token)
// --offline with no explicit --cache-dir defaults to ~/.cache/glint.
resolvedCacheDir := *cacheDir
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
p, err := model.Parse(path)
if err != nil {
@@ -182,32 +232,43 @@ Examples:
if *listVars {
printVars(p, ctx)
}
if !ctx.IsEmpty() {
// Context summary only makes sense in plain-text output; suppress it in
// structured formats so stdout contains only the machine-readable payload.
if !ctx.IsEmpty() && *format == "text" {
printContext(p, ctx)
}
findings := linter.Lint(p)
hasErrors := false
for _, f := range findings {
fmt.Println(f)
if f.Severity == linter.Error {
hasErrors = true
errCount, _ := countSeverities(findings)
// In structured formats the summary line goes to stderr so stdout is clean.
summaryOut := os.Stdout
if *format != "text" {
summaryOut = os.Stderr
}
switch *format {
case "json":
writeJSON(os.Stdout, findings, path)
case "sarif":
writeSARIF(os.Stdout, findings, path)
case "junit":
writeJUnit(os.Stdout, findings, path)
case "github":
writeGitHub(os.Stdout, findings)
default: // "text"
for _, f := range findings {
fmt.Println(f)
}
}
if len(findings) == 0 {
fmt.Printf("OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
fmt.Fprintf(summaryOut, "OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
} else {
errCount := 0
for _, f := range findings {
if f.Severity == linter.Error {
errCount++
}
}
fmt.Printf("%d finding(s): %d error(s)\n", len(findings), errCount)
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount)
}
if hasErrors {
if errCount > 0 {
os.Exit(1)
}
}
@@ -227,6 +288,8 @@ func cmdGraph(args []string) {
fs := flag.NewFlagSet("glint graph", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
@@ -315,7 +378,12 @@ Examples:
}
path := fs.Arg(0)
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token)
resolvedCacheDir := *cacheDir
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
p, err := model.Parse(path)
if err != nil {