diff --git a/CHANGELOG.md b/CHANGELOG.md index 6057245..bfd0517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.2.22] - 2026-06-25 + +### Added + +- **Multi-context simulation** — `glint check` now accepts a repeatable `--context KEY=VALUE[,...]` flag. Each `--context` invocation defines one simulation context; when two or more are given, glint evaluates every pipeline job across all contexts and prints a side-by-side comparison table with `active`, `manual`, `skipped`, or `blocked` (when `workflow:rules:` would prevent the pipeline from starting) per column. Known context keys are `branch`, `tag`, and `source` (case-insensitive); any other `KEY=VALUE` pair is treated as a CI variable override. The `--changes` / `--changes-from` flags work alongside `--context` and the changed-file list is shared across all contexts. Implicit `--branch main --source push` defaults are skipped when `--context` is given. + ## [0.2.21] - 2026-06-21 ### Added diff --git a/FEATURES.md b/FEATURES.md index 9760ad0..1d23102 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -151,6 +151,24 @@ Use `--list-vars` to print the resolved variable table to stderr. | `--changes PATH` | Mark PATH as changed; repeatable | | `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files | +**Multi-context comparison** (`--context`): + +Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context. + +Known context keys: `branch`, `tag`, `source` (case-insensitive). Any other `KEY=VALUE` pair is injected as a CI variable override. The `--changes`/`--changes-from` file list is shared across all contexts. + +``` +glint check --context branch=main --context branch=develop .gitlab-ci.yml + +Context comparison: + +JOB branch=main branch=develop +---------- ----------- ------------- +build-job active active +deploy-job active skipped +test-job active active +``` + --- ## Output formats diff --git a/README.md b/README.md index 5f78d51..81bfbe3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. @@ -17,7 +17,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G - **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001–GL043); run `glint explain ` for any rule - **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support -- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped +- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations) - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG diff --git a/ROADMAP.md b/ROADMAP.md index 90ef17c..b4edb88 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap - ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected - ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions - ~~**`rules:changes:` evaluation**~~ — ✓ shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided -- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table (`--context branch=main --context branch=develop --context tag=v1.0.0`) +- ~~**Multi-context simulation**~~ — ✓ shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts - **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context --- diff --git a/cmd/glint/main.go b/cmd/glint/main.go index acd1476..40d7c19 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -126,6 +126,8 @@ func cmdCheck(args []string) { var changesFiles multiFlag fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable") changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)") + var contexts multiFlag + fs.Var(&contexts, "context", "simulation context as KEY=VALUE[,...]; repeatable for multi-context comparison table") fs.Usage = func() { fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file. @@ -190,6 +192,15 @@ Options: Run "git diff --name-only " to determine changed files for rules:changes: evaluation. Combined with --changes if both are given. + --context + Define a simulation context. Repeatable: each --context flag adds one + column to a comparison table showing every job's state across contexts. + Known keys: branch, tag, source. Any other KEY=VALUE is treated as a + CI variable override. Examples: + --context branch=main --context branch=develop + --context tag=v1.0.0 + --context branch=main,DEPLOY_ENV=prod + --list-vars Print all pipeline-level variables collected from the root file and every included file (sorted KEY=VALUE) to stderr, then continue @@ -219,12 +230,14 @@ Examples: glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml glint check --changes-from origin/main .gitlab-ci.yml + glint check --context branch=main --context branch=develop .gitlab-ci.yml + glint check --context branch=main --context tag=v1.0.0 --context source=schedule .gitlab-ci.yml `) } _ = fs.Parse(args) - // Apply implicit defaults when no context flag is given at all. - if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 { + // Apply implicit defaults only in single-context mode when no flags are given. + if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 { *branch = "main" *source = "push" } @@ -308,40 +321,62 @@ Examples: fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base) } - ctx := cicontext.New(*branch, *tag, *source, vars) - // Wire up rules:changes: evaluation when file-change data is provided. + // Compute changed files once; shared across single and multi-context modes. + var allChanged []string + var changesReliable bool if *changesFrom != "" || len(changesFiles) > 0 { - var allChanged []string - reliable := len(changesFiles) > 0 + changesReliable = len(changesFiles) > 0 if *changesFrom != "" { files, err := gitDiffFiles(*changesFrom) if err != nil { fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err) } else { allChanged = append(allChanged, files...) - reliable = true + changesReliable = true } } allChanged = append(allChanged, changesFiles...) - if reliable { - if allChanged == nil { - allChanged = []string{} + if changesReliable && allChanged == nil { + allChanged = []string{} + } + } + + if len(contexts) > 0 { + // Multi-context mode: build one context per --context flag, then print a comparison table. + ctxList := make([]*cicontext.Context, 0, len(contexts)) + runs := make([]bool, 0, len(contexts)) + for _, spec := range contexts { + cb, ct, cs, cv := parseContextSpec(spec) + c := cicontext.New(cb, ct, cs, cv) + if changesReliable { + c.SetChangedFiles(allChanged) } + ran := enrichContext(c, p) + ctxList = append(ctxList, c) + runs = append(runs, ran) + } + if *format == "text" { + printContextTable(p, ctxList, contexts, runs) + } + } else { + // Single-context mode: existing flow. + ctx := cicontext.New(*branch, *tag, *source, vars) + if changesReliable { ctx.SetChangedFiles(allChanged) } - } - if !ctx.IsEmpty() { - if !enrichContext(ctx, p) { - fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path) + if !ctx.IsEmpty() { + if !enrichContext(ctx, p) { + fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path) + } + } + if *listVars { + printVars(p, ctx) + } + // 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) } - } - if *listVars { - printVars(p, ctx) - } - // 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) @@ -679,3 +714,136 @@ func printJobGroup(label string, jobs []string) { } fmt.Printf("%s (%d): %s\n", label, len(jobs), strings.Join(jobs, ", ")) } + +// parseContextSpec parses "branch=main,DEPLOY_ENV=prod" into its parts. +// Known keys (branch, tag, source) are extracted; everything else goes into extraVars. +func parseContextSpec(spec string) (branch, tag, source string, extraVars []string) { + for _, kv := range strings.Split(spec, ",") { + kv = strings.TrimSpace(kv) + if kv == "" { + continue + } + parts := strings.SplitN(kv, "=", 2) + key := parts[0] + val := "" + if len(parts) == 2 { + val = parts[1] + } + switch strings.ToLower(key) { + case "branch": + branch = val + case "tag": + tag = val + case "source": + source = val + default: + extraVars = append(extraVars, kv) + } + } + return +} + +// sortedJobNames returns non-hidden job names ordered by stage position, then alphabetically. +func sortedJobNames(p *model.Pipeline) []string { + stageIdx := make(map[string]int, len(p.Stages)) + for i, s := range p.Stages { + stageIdx[s] = i + } + type entry struct { + name string + stage int + } + entries := make([]entry, 0, len(p.Jobs)) + for name, job := range p.Jobs { + if strings.HasPrefix(name, ".") { + continue + } + idx, ok := stageIdx[job.Stage] + if !ok { + idx = len(p.Stages) + } + entries = append(entries, entry{name: name, stage: idx}) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].stage != entries[j].stage { + return entries[i].stage < entries[j].stage + } + return entries[i].name < entries[j].name + }) + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.name + } + return names +} + +// printContextTable prints a side-by-side comparison of job states across contexts. +func printContextTable(p *model.Pipeline, ctxs []*cicontext.Context, labels []string, runs []bool) { + jobs := sortedJobNames(p) + if len(jobs) == 0 { + return + } + + // Evaluate all jobs for all contexts up front so we can compute column widths. + states := make([][]string, len(jobs)) + for r, name := range jobs { + states[r] = make([]string, len(ctxs)) + for c, ctx := range ctxs { + if !runs[c] { + states[r][c] = "blocked" + } else { + switch cicontext.EvalJob(p.Jobs[name], ctx) { + case cicontext.JobActive: + states[r][c] = "active" + case cicontext.JobManual: + states[r][c] = "manual" + default: + states[r][c] = "skipped" + } + } + } + } + + // Compute column widths. + jobCol := len("JOB") + for _, name := range jobs { + if len(name) > jobCol { + jobCol = len(name) + } + } + ctxCols := make([]int, len(labels)) + for i, lbl := range labels { + ctxCols[i] = len(lbl) + } + for r := range jobs { + for c, s := range states[r] { + if len(s) > ctxCols[c] { + ctxCols[c] = len(s) + } + } + } + + // Print header. + fmt.Println("Context comparison:") + fmt.Println() + fmt.Printf("%-*s", jobCol, "JOB") + for i, lbl := range labels { + fmt.Printf(" %-*s", ctxCols[i], lbl) + } + fmt.Println() + fmt.Print(strings.Repeat("-", jobCol)) + for _, w := range ctxCols { + fmt.Print(" " + strings.Repeat("-", w)) + } + fmt.Println() + + // Print rows. + for r, name := range jobs { + fmt.Printf("%-*s", jobCol, name) + for c, s := range states[r] { + fmt.Printf(" %-*s", ctxCols[c], s) + } + fmt.Println() + } + fmt.Println() +} diff --git a/cmd/glint/main_test.go b/cmd/glint/main_test.go index b6d696b..d279cd4 100644 --- a/cmd/glint/main_test.go +++ b/cmd/glint/main_test.go @@ -823,6 +823,355 @@ build-job: } } +// ── parseContextSpec ───────────────────────────────────────────────────────── + +func TestParseContextSpec(t *testing.T) { + cases := []struct { + spec string + branch string + tag string + source string + extraVars []string + }{ + {"branch=main", "main", "", "", nil}, + {"tag=v1.0.0", "", "v1.0.0", "", nil}, + {"source=schedule", "", "", "schedule", nil}, + {"branch=main,source=push", "main", "", "push", nil}, + {"branch=main,DEPLOY=prod", "main", "", "", []string{"DEPLOY=prod"}}, + {"DEPLOY=prod,ENV=staging", "", "", "", []string{"DEPLOY=prod", "ENV=staging"}}, + {"branch=main,tag=v1,source=push,X=y", "main", "v1", "push", []string{"X=y"}}, + {"branch=main, ,source=push", "main", "", "push", nil}, // spaces and empty segments + {"", "", "", "", nil}, + {"noequals", "", "", "", []string{"noequals"}}, // no = → whole token as extraVar + {"BRANCH=develop", "develop", "", "", nil}, // case-insensitive key matching + } + for _, tc := range cases { + t.Run(tc.spec, func(t *testing.T) { + b, tg, s, ev := parseContextSpec(tc.spec) + if b != tc.branch { + t.Errorf("branch: want %q got %q", tc.branch, b) + } + if tg != tc.tag { + t.Errorf("tag: want %q got %q", tc.tag, tg) + } + if s != tc.source { + t.Errorf("source: want %q got %q", tc.source, s) + } + if len(ev) != len(tc.extraVars) { + t.Errorf("extraVars len: want %d got %d (%v)", len(tc.extraVars), len(ev), ev) + return + } + for i := range ev { + if ev[i] != tc.extraVars[i] { + t.Errorf("extraVars[%d]: want %q got %q", i, tc.extraVars[i], ev[i]) + } + } + }) + } +} + +// ── sortedJobNames ──────────────────────────────────────────────────────────── + +func TestSortedJobNames_Order(t *testing.T) { + p := &model.Pipeline{ + Stages: []string{"build", "test", "deploy"}, + Jobs: map[string]model.Job{ + "deploy-job": {Stage: "deploy"}, + "test-b": {Stage: "test"}, + "test-a": {Stage: "test"}, + "build-job": {Stage: "build"}, + ".hidden": {Stage: "build"}, // excluded + }, + } + got := sortedJobNames(p) + want := []string{"build-job", "test-a", "test-b", "deploy-job"} + if len(got) != len(want) { + t.Fatalf("want %v got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d]: want %q got %q", i, want[i], got[i]) + } + } +} + +func TestSortedJobNames_UnknownStage(t *testing.T) { + p := &model.Pipeline{ + Stages: []string{"build"}, + Jobs: map[string]model.Job{ + "build-job": {Stage: "build"}, + "orphan-job": {Stage: "nonexistent"}, + }, + } + got := sortedJobNames(p) + if len(got) != 2 { + t.Fatalf("want 2 jobs, got %v", got) + } + if got[0] != "build-job" { + t.Errorf("expected build-job first, got %q", got[0]) + } + if got[1] != "orphan-job" { + t.Errorf("expected orphan-job last, got %q", got[1]) + } +} + +func TestSortedJobNames_Empty(t *testing.T) { + p := &model.Pipeline{ + Stages: []string{"build"}, + Jobs: map[string]model.Job{".hidden": {Stage: "build"}}, + } + got := sortedJobNames(p) + if len(got) != 0 { + t.Errorf("want empty, got %v", got) + } +} + +// ── printContextTable ───────────────────────────────────────────────────────── + +func TestPrintContextTable_Empty(t *testing.T) { + // Pipeline with no visible jobs: should return without printing. + p := &model.Pipeline{ + Stages: []string{"build"}, + Jobs: map[string]model.Job{".hidden": {Stage: "build"}}, + } + // No panic expected, no output to verify. + printContextTable(p, nil, nil, nil) +} + +func TestPrintContextTable_ActiveSkipped(t *testing.T) { + // Job always active. + content := ` +stages: [build, deploy] +build-job: + stage: build + script: make +deploy-job: + stage: deploy + script: make deploy + rules: + - if: '$CI_COMMIT_BRANCH == "main"' +` + path := writePipeline(t, content) + p, err := model.Parse(path) + if err != nil { + t.Fatal(err) + } + + ctx1 := cicontext.New("main", "", "push", nil) + ctx2 := cicontext.New("develop", "", "push", nil) + enrichContext(ctx1, p) + enrichContext(ctx2, p) + + // Just ensure it doesn't panic; output goes to real stdout in tests. + printContextTable(p, []*cicontext.Context{ctx1, ctx2}, + []string{"branch=main", "branch=develop"}, []bool{true, true}) +} + +func TestPrintContextTable_Blocked(t *testing.T) { + // A context where workflow:rules: blocks the pipeline. + content := ` +stages: [build] +workflow: + rules: + - if: '$CI_COMMIT_BRANCH == "main"' +build-job: + stage: build + script: make +` + path := writePipeline(t, content) + p, err := model.Parse(path) + if err != nil { + t.Fatal(err) + } + + ctx1 := cicontext.New("main", "", "push", nil) + ctx2 := cicontext.New("develop", "", "push", nil) + r1 := enrichContext(ctx1, p) + r2 := enrichContext(ctx2, p) + + printContextTable(p, []*cicontext.Context{ctx1, ctx2}, + []string{"branch=main", "branch=develop"}, []bool{r1, r2}) +} + +func TestPrintContextTable_ManualState(t *testing.T) { + content := ` +stages: [build] +build-job: + stage: build + script: make + rules: + - when: manual +` + path := writePipeline(t, content) + p, err := model.Parse(path) + if err != nil { + t.Fatal(err) + } + + ctx := cicontext.New("main", "", "push", nil) + enrichContext(ctx, p) + printContextTable(p, []*cicontext.Context{ctx}, []string{"branch=main"}, []bool{true}) +} + +func TestPrintContextTable_ShortLabel(t *testing.T) { + // Short label "x" (1 char) ensures a state string ("skipped", 7 chars) triggers + // the ctxCols[c] = len(s) branch in printContextTable. + content := ` +stages: [build] +build-job: + stage: build + script: make + rules: + - if: '$CI_COMMIT_TAG != ""' + when: on_success + - when: never +` + path := writePipeline(t, content) + p, err := model.Parse(path) + if err != nil { + t.Fatal(err) + } + + ctx := cicontext.New("main", "", "push", nil) + enrichContext(ctx, p) + // Label "x" is shorter than "skipped" (7 chars), covering ctxCols width-expansion. + printContextTable(p, []*cicontext.Context{ctx}, []string{"x"}, []bool{true}) +} + +// ── cmdCheck multi-context ─────────────────────────────────────────────────── + +func TestCmdCheck_MultiContext_Single(t *testing.T) { + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + cmdCheck([]string{"--context", "branch=main", path}) + if *code != -1 { + t.Errorf("multi-context single: want no exit, got %d", *code) + } +} + +func TestCmdCheck_MultiContext_Multiple(t *testing.T) { + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + cmdCheck([]string{"--context", "branch=main", "--context", "branch=develop", path}) + if *code != -1 { + t.Errorf("multi-context multiple: want no exit, got %d", *code) + } +} + +func TestCmdCheck_MultiContext_WithExtraVar(t *testing.T) { + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + cmdCheck([]string{"--context", "branch=main,DEPLOY=prod", path}) + if *code != -1 { + t.Errorf("multi-context with extra var: want no exit, got %d", *code) + } +} + +func TestCmdCheck_MultiContext_ErrorPipeline(t *testing.T) { + code := captureExit(t) + content := ` +stages: [build] +test-job: + stage: nonexistent + script: echo +` + path := writePipeline(t, content) + cmdCheck([]string{"--context", "branch=main", path}) + if *code != 1 { + t.Errorf("multi-context error pipeline: want exit(1), got %d", *code) + } +} + +func TestCmdCheck_MultiContext_SkipsImplicitDefaults(t *testing.T) { + // When --context is given, implicit defaults (branch=main, source=push) must not be set. + // This is a smoke test: the command must complete without panicking. + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + cmdCheck([]string{"--context", "tag=v1.0.0", path}) + if *code == 2 { + t.Errorf("multi-context tag: unexpected exit(2)") + } +} + +func TestCmdCheck_MultiContext_WithChanges(t *testing.T) { + code := captureExit(t) + content := ` +stages: [build] +build-job: + stage: build + script: make + rules: + - changes: [src/**] + when: on_success +` + path := writePipeline(t, content) + cmdCheck([]string{ + "--context", "branch=main", + "--changes", "src/app.go", + path, + }) + if *code != -1 { + t.Errorf("multi-context+changes: want no exit, got %d", *code) + } +} + +func TestCmdCheck_MultiContext_WithChangesFrom(t *testing.T) { + orig := execCommandOutput + execCommandOutput = func(name string, args ...string) ([]byte, error) { + return []byte("src/app.go\n"), nil + } + t.Cleanup(func() { execCommandOutput = orig }) + + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path}) + if *code != -1 { + t.Errorf("multi-context+changes-from: want no exit, got %d", *code) + } +} + +func TestCmdCheck_MultiContext_ChangesFrom_EmptyDiff(t *testing.T) { + orig := execCommandOutput + execCommandOutput = func(name string, args ...string) ([]byte, error) { + return []byte(""), nil + } + t.Cleanup(func() { execCommandOutput = orig }) + + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + // reliable=true, allChanged nil → allChanged = []string{} guard hit in multi-context path + cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path}) + if *code != -1 { + t.Errorf("multi-context+changes-from empty diff: want no exit, got %d", *code) + } +} + +func TestCmdCheck_MultiContext_ChangesFrom_Fails(t *testing.T) { + orig := execCommandOutput + execCommandOutput = func(name string, args ...string) ([]byte, error) { + return nil, errors.New("not a git repository") + } + t.Cleanup(func() { execCommandOutput = orig }) + + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + // git fails → changesReliable stays false → SetChangedFiles not called + cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path}) + if *code != -1 { + t.Errorf("multi-context+changes-from fail: want no exit, got %d", *code) + } +} + +func TestCmdCheck_MultiContext_FormatJSON(t *testing.T) { + // --context + non-text format: printContextTable should NOT be called. + code := captureExit(t) + path := writePipeline(t, minimalPipeline) + cmdCheck([]string{"--context", "branch=main", "--format", "json", path}) + if *code != -1 { + t.Errorf("multi-context json: want no exit, got %d", *code) + } +} + // ── isSuppressed ───────────────────────────────────────────────────────────── func TestIsSuppressed(t *testing.T) {