Skip to content

refactor: replace the package-level run state with a struct [CLI-1986] - #7412

Merged
PeterSchafer merged 1 commit into
mainfrom
refactor/CLI-1986_per-run-state
Oct 26, 2026
Merged

PeterSchafer merged 1 commit into
mainfrom
refactor/CLI-1986_per-run-state

Conversation

@PeterSchafer

@PeterSchafer PeterSchafer commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

CLI-1986

Pull Request Submission Checklist

  • Follows CONTRIBUTING guidelines
  • Commit messages are release-note ready, emphasizing what was changed, not how.
  • Includes detailed description of changes
  • Contains risk assessment (Low | Medium | High)
  • Highlights breaking API changes (if applicable)
  • Links to automated tests covering new functionality
  • Includes manual testing instructions (if necessary)
  • Updates relevant GitBook documentation (PR link: ___)
  • Includes product update to be announced in the next stable release notes

What does this PR do?

The engine, configuration, logger, context and help flag of a run were package-level variables that tests had to set and reset. They now live in a cliRun, created once per run by execute and handed to the commands, the teardown and the helpers, which became its methods. execute returns the run with the exit code, so tests inspect the run they started.

No behavior changes. First of a stack of 2; independent of the other.

Where should the reviewer start?

cliRun and execute in cliv2/pkg/core/main.go. The rest moves functions to methods and tests to newTestRun.

How should this be manually tested?

Not necessary. As a regression check, six existing acceptance specs (112 tests) give identical results with the main binary and this branch: 62 pass and 50 fail on both, the latter because the legacy CLI binary is missing locally.

What's the product update that needs to be communicated to CLI users?

None.

Risk assessment (Low | Medium | High)?

Low. A mechanical refactor covered by the unit tests.

🤖 Generated with Claude Code


Note

Low Risk
Mechanical state encapsulation with broad test updates; intended behavior parity with no new auth or network paths.

Overview
Replaces package-level run state (globalEngine, globalConfiguration, globalLogger, globalContext, helpProvided, interactionId) with a per-invocation cliRun struct created in new execute, which returns the run plus exit code for tests.

Command wiring, teardown, analytics/instrumentation, debug log header/footer, and error display are cliRun methods that read r.config, r.engine, and r.logger instead of globals. interactionId lives on the run (debug header shows r.interactionId). Teardown and Cobra execution thread a single context through ExecuteContext, tearDown, and displayError; teardown’s timeout is derived from that context rather than always context.Background().

Tests drop global cleanup() in favor of newTestRun() and assert against the cliRun returned from execute; a few tests lock in per-run interaction id and JSON error path behavior.

Reviewed by Cursor Bugbot for commit 30ee340. Bugbot is set up for automated code reviews on this repo. Configure here.

@PeterSchafer
PeterSchafer requested review from a team as code owners October 25, 2026 15:36
@snyk-io

snyk-io Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@snyk-pr-review-bot

This comment has been minimized.

@PeterSchafer
PeterSchafer force-pushed the refactor/CLI-1986_per-run-state branch from 7f9026a to 3f22a70 Compare October 25, 2026 15:45
@snyk-pr-review-bot

This comment has been minimized.

…[CLI-1986]

The context of a run was a field of the run, which the containedctx linter
rejects. The commands now take it from cobra, which carries it from
ExecuteContext, and the teardown and the error output are given it as a
parameter. The teardown context derives from it, so the requests of the
teardown carry the interaction id of the run like all other requests.

refactor: replace the package-level run state with a struct [CLI-1986]

The engine, the configuration, the logger, the context and the help flag of
a run were package-level variables, reachable from everywhere, and tests had
to set and reset them. They now live in a cliRun that execute creates once
per run and hands to the commands, to the teardown and to the helpers that
used the variables. Everything that needs them is a method of cliRun, so
every function says what it uses.

execute returns the run together with the exit code, so tests inspect the
configuration of the run they started instead of a global.

No behavior changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@PeterSchafer
PeterSchafer force-pushed the refactor/CLI-1986_per-run-state branch from 3f22a70 to 30ee340 Compare October 25, 2026 16:28
@snyk-pr-review-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected
📚 Repository Context Analyzed

This review considered 35 relevant code sections from 11 files (average relevance: 0.95)

🤖 Repository instructions applied (from AGENTS.md)

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor
Warnings
⚠️

"[refactor: pass the context of a run explicitly instead of storing it CLI-1986](https://api.github.com/repos/snyk/cli/git/commits/30ee340a5d92bfd8809073ce9c96948dff0da277)" is too long. Keep the first line of your commit message under 72 characters.

Generated by 🚫 dangerJS against 30ee340

Comment on lines +34 to +35
config := r.config
networkAccess := r.engine.GetNetworkAccess()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you could just change usage in the method instead of assigning the old names.

@basti-snyk basti-snyk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review panel (AI-generated, 11 reviewer lenses)

Scope: 6 files in cliv2/pkg/core, +332/-289. Tests touched: yes. Public API touched: no. Run and WithAdditionalExtensions keep their signatures, and the only callers outside the package (cliv2/cmd, cliv2-private/cmd) use nothing else.
Size: Large

Verdict: COMMENT. The refactor is mechanical, and every lens found the same runtime behaviour as main. No package-level engine, config, logger, context, help flag or interaction id is left. Only internalOS and buildType remain, both set at build time. The PR is large, but the tests are thorough and nothing outside the package changes, so nothing here blocks. One question to answer before merge: the teardown context (inline at main.go:554). Eight of eleven lenses raised it independently.

Raised by several lenses (non-blocking, so not posted inline):

  • The interaction id goes through AssembleUrnFromUUID again at main.go:622, 690 and 729, even though main.go:611 already stores a URN. GAF returns the input unchanged when it already has the prefix, so this is dead code, and the field comment at main.go:101 reads as if the value were a raw UUID. Raised by 9 lenses.
  • Locals that only rename fields: debugLogger := r.logger (instrumentation.go:93), eng, logger := r.engine, r.logger (instrumentation.go:126), config := r.config (logheaderfooter.go:34, :102). sendInstrumentation now reads both eng.GetConfiguration() and r.config, and the test feeds it two different configs, so it can't catch them drifting apart. Raised by 8 lenses.
  • The cliRun comment (main.go:93) says only internalOS stays outside the struct, but buildType (debug.go:16) is injected with -X too. Raised by 7 lenses.
  • setupTearDownRun builds a second discard logger (main_test.go:1043-1044) right after newTestRun() made one. Raised by 6 lenses.
  • logheaderfooter.go:74 declares r, _ := regexp.Compile(...) inside what is now a (r *cliRun) method, which shadows the receiver. Rename it to re.
  • Some helpers became methods while others (runWorkflowAndProcessData, handleRetryNotification, populateRedactionTerms, tearDown's startTime/ua/cliAnalytics) still take pieces of the run as arguments. Pick one rule, here or in PR 2.
  • Follow-up: errorList and errorListMutex are still locals in execute that closures mutate, and os.Args is still read directly, so the tests can't go t.Parallel() yet.

PR description: the only test evidence is "62 pass / 50 fail on both". Please name the six specs so a reviewer can rerun them. A concrete manual check would help too, e.g. diff the snyk whoami -d header, footer and exit code before and after. The description should also mention that context now flows through ExecuteContext and cmd.Context(). That's equivalent today, but it's more than a rename.

FYI, unrelated to this PR: Test_mainWithErrorCode/outputs_an_error_list (main_test.go:113) fails locally on both this branch and the merge base (expected 2, actual 0). execute reads the developer's stored credentials, which win over SNYK_TOKEN=invalidToken. We couldn't run -race here because there is no cgo toolchain.

🤖 Generated with Claude Code

Comment thread cliv2/pkg/core/main.go
func (r *cliRun) tearDown(ctx context.Context, err error, errorList []error, startTime time.Time, ua networking.UserAgentInfo, cliAnalytics analytics.Analytics) int {
// Create a context with timeout for teardown operations to ensure we don't hang indefinitely
teardownCtx, cancel := context.WithTimeout(context.Background(), teardownTimeout)
teardownCtx, cancel := context.WithTimeout(ctx, teardownTimeout)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[scenario-walker, architect, rigor-reviewer, error-path-reviewer, diff-auditor, security-escalator, necessity-hawk, consumer-advocate] should-fix: teardownCtx used to come from context.Background(). Now it comes from the run's ctx. Today that changes nothing, because ctx is Background plus a value and nothing cancels it. But the doc comment two lines up says teardown also runs on signal-triggered exit. Once anyone makes ctx cancellable (signal.NotifyContext, a run-level timeout), teardown starts out cancelled. Analytics, instrumentation and GLOBAL_CLEANUP would then fail quietly on exactly the exits where we need them. tearDown only needs ctx to pass the interaction id to displayError. Suggestion:

teardownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teardownTimeout)

Or keep context.Background() here and pass ctx only to displayError. Either way, a PR that claims no behaviour change shouldn't move this as a side effect. No test pins it, because the teardown tests pass t.Context().

Comment thread cliv2/pkg/core/main.go

// run the extensible cli
err = rootCommand.Execute()
err = rootCommand.ExecuteContext(ctx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[scenario-walker] should-fix, needs a test: Execute() → ExecuteContext(ctx) together with globalContext → cmd.Context() (line 225) is the one piece of new wiring here, and nothing tests it. setupMainWorkflowTestEnv sets cmd.SetContext(t.Context()) itself. Test_CreateCommandsForWorkflowWithSubcommands calls RunE directly with a nil context, which only passes because GAF's WithContext ignores nil. If someone reverts this to Execute(), every workflow loses InteractionIdKey from its context and every test stays green. One test that runs the root command through ExecuteContext and checks that the invoked workflow's invocation.Context() carries the interaction id would pin it.

return out.String()
}

func Test_writeLogHeader_showsInteractionIdOfTheRun(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[duplication-hunter, fixture-reviewer] should-fix: this new test repeats the setup in writeLogHeaderWithMachineID just above it almost line by line: config, engine, SetRuntimeInfo, buffer logger, newTestRun, wiring. It even wires the run differently, with one tuple assignment here and three separate assignments in the helper. Why do we do the same thing two ways? Could the helper take the interaction id, or return the run before writeLogHeader(), so both tests reuse it? Small one: the assertion on line 49 hardcodes the %-22s padding. assert.Regexp(t, Interaction:\s+run-1234, ...) checks the same thing and won't break when the column width changes.


userInterface := mocks.NewMockUserInterface(gomock.NewController(t))
userInterface.EXPECT().Output(gomock.Any()).DoAndReturn(func(out string) error {
assert.Contains(t, out, `"path": "/some/dir"`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[assertion-sharpener, architect] should-fix: could we have a more definitive assertion here? assert.Contains(out, "path": "/some/dir") depends on MarshalIndent formatting and only proves the text shows up somewhere. Unmarshal out into JsonErrorStruct and assert Ok == false, Path == "/some/dir" and the exact ErrorMsg. Then a wrong field or a broken shape fails the test.

@@ -91,20 +92,18 @@ func Test_isHtmlOutput(t *testing.T) {
}

func Test_mainWithErrorCode(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[naming-reviewer, rigor-reviewer] should-fix: Test_mainWithErrorCode now calls execute(nil) in its main body (line 99). Only the subtest at line 113 still goes through mainWithErrorCode. The name no longer matches what most of the test exercises. Rename it to Test_execute, or split it so each test is named after the function it calls. Alternatively, Run could call execute directly and mainWithErrorCode could go away, which leaves one entry point.

globalConfiguration = configuration.NewWithOpts()
globalConfiguration.Set(configuration.ANALYTICS_DISABLED, true)
globalEngine = workflow.NewWorkFlowEngine(globalConfiguration)
r := newTestRun()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[duplication-hunter] should-fix: newTestRun(), then a new config, a Set, then NewWorkFlowEngine(r.config). This pattern now appears here and, nearly verbatim, at lines 321, 385, 623 and 711. The duplication predates this PR, but the PR rewrites every one of those lines and adds newTestRun, so this is the cheap moment to fold it in. Can we reduce it? For example a newTestRunWithEngine(config) that sets both config and engine.

authorization, _, userAgent := logHeaderAuthorizationInfo(config, networkAccess)
func (r *cliRun) writeLogHeader() {
config := r.config
authorization, _, userAgent := r.logHeaderAuthorizationInfo()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder, if we can get rid of the oAuthEnabled return param.

Comment thread cliv2/pkg/core/main.go
// cliRun is what one run of the CLI works on. It is created once per run and handed to the commands and to the
// teardown, so every function says what it uses. Only what is injected at build time (internalOS) is kept
// outside of it.
type cliRun struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this.

Comment thread cliv2/pkg/core/main.go
initDebugBuild()

noopLogger := zerolog.New(io.Discard)
r := &cliRun{logger: &noopLogger, interactionId: instrumentation.AssembleUrnFromUUID(uuid.NewString())}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why a noopLogger instead of a real one?

Comment thread cliv2/pkg/core/main.go
type cliRun struct {
engine workflow.Engine
config configuration.Configuration
logger *zerolog.Logger

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just have an accessor Method Logger() that takes the engine logger?

Comment thread cliv2/pkg/core/main.go
})
networkAccess.AddHeaderField("x-snyk-cli-version", cliv2.GetFullVersion())
networkAccess.AddHeaderField("snyk-interaction-id", instrumentation.AssembleUrnFromUUID(interactionId))
networkAccess.AddHeaderField("snyk-interaction-id", instrumentation.AssembleUrnFromUUID(r.interactionId))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably need to think about how this can be set by top-level workflow indication rather than CLI main method invocation, e.g. through a pre-invoke hook.

// newTestRun returns what a run of the CLI works on, with a quiet logger. Tests set the configuration and the
// engine they need.
func newTestRun() *cliRun {
logger := zerolog.New(io.Discard)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not nooplogger here?

@basti-snyk basti-snyk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one! Some questions and nits, but nothing blocking.

@PeterSchafer
PeterSchafer merged commit 458cc33 into main Oct 26, 2026
10 of 11 checks passed
@PeterSchafer
PeterSchafer deleted the refactor/CLI-1986_per-run-state branch October 26, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants