Repository: onflow/flow-cli (go)

Issue:
Add `flow cadence lint` command
## Instructions

Please fill out the template below to the best of your ability.

### Issue To Be Solved

Currently, the [Cadence linter](https://github.com/onflow/cadence-tools/tree/master/lint) is only really surfaced through the language server & adjacent tools (VSCode extension, playground).  It is a valuable static-analysis tool which could be a part of automation processes and developers may want to run it independently (outside of integration with the aforementioned tooling).

We should surface this as a command in the Flow CLI so that developers can enforce better standards within their Cadence code and catch errors earlier in their development cycle.

### (Optional): Suggest A Solution

Add a `flow cadence lint <files>` command which runs all linting analyzers as and surfaces these to the output.  The command should also attempt to surface any parsing/checking errors in a structured format.

### (Optional): Context

This relates to efforts related to Cadence 1.0 and helps developers understand if problems exist in their code, what these problems are, and how they can attempt to mediate them.

related: https://github.com/onflow/cadence-tools/pull/285

Pull request description:
Add `flow cadence lint` command
Closes #1395 

## Description

- Adds `flow cadence lint [files]` to the CLI which mimics diagnostics currently available in LS
- Syntax/semantic errors are captured as well & while non-terminal, will cause the linter to exit with code 1 when finished

_Notes:_

This PR has quite a bit of overlap with the Cadence Language Server.  Unfortunately, it's tricky to refactor this logic into a shared library, and the implementation has to be fairly nuanced.  I tried really hard to use any existing APIs for linting/analysis, but none of them fit the needs here - for similar reasons as the LS so this is something for further exploration.

In order to meet product deadlines & surface this feature to users ASAP, I suggest that we start by inserting the logic into the CLI.  We can work on refactoring this & the LS into a common library (probably inside of the cadence-tools/lint pacakge itself) in a subsequent ticket, carefully considering what this shared interface should actually look like (& try to keep the implementation generalized enough for other users as well).

______

For contributor use:

- [x] Targeted PR against `master` branch
- [x] Linked to Github issue with discussion and accepted design OR link to spec that describes this work
- [x] Code follows the [standards mentioned here](https://github.com/onflow/flow-cli/blob/master/CONTRIBUTING.md#styleguides)
- [ ] Updated relevant documentation
- [x] Re-reviewed `Files changed` in the Github PR explorer
- [x] Added appropriate labels


Patch:
diff --git a/cmd/flow/main.go b/cmd/flow/main.go
index 6744ced3..fc381590 100644
--- a/cmd/flow/main.go
+++ b/cmd/flow/main.go
@@ -163,8 +163,4 @@ func main() {
 	if err := cmd.Execute(); err != nil {
 		util.Exit(1, err.Error())
 	}
-
-	if status := *test.TestCommand.Status; status > 0 {
-		os.Exit(int(status))
-	}
 }
diff --git a/go.mod b/go.mod
index a2407889..8361c0ef 100644
--- a/go.mod
+++ b/go.mod
@@ -8,9 +8,11 @@ require (
 	github.com/getsentry/sentry-go v0.27.0
 	github.com/go-git/go-git/v5 v5.11.0
 	github.com/gosuri/uilive v0.0.4
+	github.com/logrusorgru/aurora/v4 v4.0.0
 	github.com/manifoldco/promptui v0.9.0
 	github.com/onflow/cadence v1.0.0-preview.13
 	github.com/onflow/cadence-tools/languageserver v1.0.0-preview.6
+	github.com/onflow/cadence-tools/lint v1.0.0-preview.9
 	github.com/onflow/cadence-tools/test v1.0.0-preview.8
 	github.com/onflow/contract-updater/lib/go/templates v1.0.1
 	github.com/onflow/fcl-dev-wallet v0.8.0-stable-cadence.1
@@ -161,7 +163,6 @@ require (
 	github.com/libp2p/go-msgio v0.3.0 // indirect
 	github.com/lmars/go-slip10 v0.0.0-20190606092855-400ba44fee12 // indirect
 	github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
-	github.com/logrusorgru/aurora/v4 v4.0.0 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
@@ -183,7 +184,6 @@ require (
 	github.com/multiformats/go-varint v0.0.7 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 	github.com/onflow/atree v0.6.1-0.20240308163425-dc825c20b1a2 // indirect
-	github.com/onflow/cadence-tools/lint v1.0.0-preview.9 // indirect
 	github.com/onflow/crypto v0.25.0 // indirect
 	github.com/onflow/flow-core-contracts/lib/go/contracts v0.15.2-0.20240305214031-d81e0c3b42f3 // indirect
 	github.com/onflow/flow-ft/lib/go/contracts v0.7.1-0.20240305212555-29d91e18f0c1 // indirect
@@ -206,7 +206,7 @@ require (
 	github.com/prometheus/procfs v0.12.0 // indirect
 	github.com/psiemens/graceland v1.0.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
-	github.com/rivo/uniseg v0.4.4 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/rogpeppe/go-internal v1.11.0 // indirect
 	github.com/rs/cors v1.8.0 // indirect
 	github.com/schollz/progressbar/v3 v3.13.1 // indirect
diff --git a/go.sum b/go.sum
index dea293ac..cd8bed45 100644
--- a/go.sum
+++ b/go.sum
@@ -2254,8 +2254,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/retailnext/hllpp v1.0.1-0.20180308014038-101a6d2f8b52/go.mod h1:RDpi1RftBQPUCDRw6SmxeaREsAaRKnOclghuzp/WRzc=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
 github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rjeczalik/notify v0.9.1/go.mod h1:rKwnCoCGeuQnwBtTSPL9Dad03Vh2n40ePRrjvIXnJho=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
diff --git a/internal/cadence/cadence.go b/internal/cadence/cadence.go
index 2c83aa09..4ed438db 100644
--- a/internal/cadence/cadence.go
+++ b/internal/cadence/cadence.go
@@ -44,4 +44,5 @@ var Cmd = &cobra.Command{
 
 func init() {
 	Cmd.AddCommand(languageserver.Cmd)
+	lintCommand.AddToParent(Cmd)
 }
diff --git a/internal/cadence/lint.go b/internal/cadence/lint.go
new file mode 100644
index 00000000..525eb3be
--- /dev/null
+++ b/internal/cadence/lint.go
@@ -0,0 +1,236 @@
+/*
+ * Flow CLI
+ *
+ * Copyright 2019 Dapper Labs, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package cadence
+
+import (
+	"fmt"
+	"strings"
+
+	"golang.org/x/exp/slices"
+
+	"github.com/logrusorgru/aurora/v4"
+	"github.com/spf13/cobra"
+
+	"github.com/onflow/cadence/tools/analysis"
+	"github.com/onflow/flowkit/v2"
+	"github.com/onflow/flowkit/v2/output"
+
+	"github.com/onflow/flow-cli/internal/command"
+)
+
+type lintFlagsCollection struct{}
+
+type fileResult struct {
+	FilePath    string
+	Diagnostics []analysis.Diagnostic
+}
+
+type lintResult struct {
+	Results  []fileResult
+	exitCode int
+}
+
+var _ command.ResultWithExitCode = &lintResult{}
+
+var lintFlags = lintFlagsCollection{}
+
+var lintCommand = &command.Command{
+	Cmd: &cobra.Command{
+		Use:     "lint [files]",
+		Short:   "Lint Cadence code to identify potential issues or errors",
+		Example: "flow cadence lint **/*.cdc",
+		Args:    cobra.MinimumNArgs(1),
+	},
+	Flags: &lintFlags,
+	RunS:  lint,
+}
+
+type severity string
+
+const (
+	errorSeverity   severity = "error"
+	warningSeverity severity = "warning"
+)
+
+func lint(
+	args []string,
+	globalFlags command.GlobalFlags,
+	logger output.Logger,
+	flow flowkit.Services,
+	state *flowkit.State,
+) (command.Result, error) {
+	filePaths := args
+	result, err := lintFiles(state, filePaths...)
+	if err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+func lintFiles(
+	state *flowkit.State,
+	filePaths ...string,
+) (
+	*lintResult,
+	error,
+) {
+	l := newLinter(state)
+	results := make([]fileResult, 0)
+	exitCode := 0
+
+	for _, location := range filePaths {
+		diagnostics, err := l.lintFile(location)
+		if err != nil {
+			return nil, err
+		}
+
+		// Sort for consistent output
+		sortDiagnostics(diagnostics)
+		results = append(results, fileResult{
+			FilePath:    location,
+			Diagnostics: diagnostics,
+		})
+
+		// Set the exitCode to 1 if any of the diagnostics are error-level
+		// In the future, this may be configurable
+		for _, diagnostic := range diagnostics {
+			severity := getDiagnosticSeverity(diagnostic)
+			if severity == errorSeverity {
+				exitCode = 1
+				break
+			}
+		}
+	}
+
+	return &lintResult{
+		Results:  results,
+		exitCode: exitCode,
+	}, nil
+}
+
+func getDiagnosticSeverity(
+	diagnostic analysis.Diagnostic,
+) severity {
+	if isErrorDiagnostic(diagnostic) {
+		return errorSeverity
+	}
+	return warningSeverity
+}
+
+// Sort diagnostics in order of precedence: start pos -> category -> message
+func sortDiagnostics(
+	diagnostics []analysis.Diagnostic,
+) {
+	slices.SortFunc(diagnostics, func(a analysis.Diagnostic, b analysis.Diagnostic) int {
+		if r := a.Range.StartPos.Offset - b.Range.StartPos.Offset; r != 0 {
+			return r
+		}
+
+		if r := strings.Compare(a.Category, b.Category); r != 0 {
+			return r
+		}
+
+		return strings.Compare(a.Message, b.Message)
+	})
+}
+
+func renderDiagnostic(diagnostic analysis.Diagnostic) string {
+	categoryColor := aurora.RedFg
+	if getDiagnosticSeverity(diagnostic) == warningSeverity {
+		categoryColor = aurora.YellowFg
+	}
+
+	startPos := diagnostic.Range.StartPos
+	locationText := fmt.Sprintf("%s:%d:%d:", diagnostic.Location.String(), startPos.Line, startPos.Column)
+	categoryText := fmt.Sprintf("%s:", diagnostic.Category)
+
+	return fmt.Sprintf("%s %s %s",
+		aurora.Gray(12, locationText).String(),
+		aurora.Colorize(categoryText, categoryColor).String(),
+		diagnostic.Message,
+	)
+}
+
+func (r *lintResult) countProblems() (int, int) {
+	numErrors := 0
+	numWarnings := 0
+	for _, result := range r.Results {
+		for _, diagnostic := range result.Diagnostics {
+			if isErrorDiagnostic(diagnostic) {
+				numErrors++
+			} else {
+				numWarnings++
+			}
+		}
+	}
+	return numErrors, numWarnings
+}
+
+func (r *lintResult) String() string {
+	var sb strings.Builder
+
+	for _, result := range r.Results {
+		for _, diagnostic := range result.Diagnostics {
+			sb.WriteString(fmt.Sprintf("%s\n\n", renderDiagnostic(diagnostic)))
+		}
+	}
+
+	var color aurora.Color
+	numErrors, numWarnings := r.countProblems()
+	if numErrors > 0 {
+		color = aurora.RedFg
+	} else if numWarnings > 0 {
+		color = aurora.YellowFg
+	}
+
+	total := numErrors + numWarnings
+	if total > 0 {
+		sb.WriteString(aurora.Colorize(fmt.Sprintf("%d %s (%d %s, %d %s)", total, pluralize("problem", total), numErrors, pluralize("error", numErrors), numWarnings, pluralize("warning", numWarnings)), color).String())
+	} else {
+		sb.WriteString(aurora.Green("Lint passed").String())
+	}
+
+	return sb.String()
+}
+
+func (r *lintResult) JSON() interface{} {
+	return r
+}
+
+func (r *lintResult) Oneliner() string {
+	numErrors, numWarnings := r.countProblems()
+	total := numErrors + numWarnings
+
+	if total > 0 {
+		return fmt.Sprintf("%d %s (%d %s, %d %s)", total, pluralize("problem", total), numErrors, pluralize("error", numErrors), numWarnings, pluralize("warning", numWarnings))
+	}
+	return "Lint passed"
+}
+
+func (r *lintResult) ExitCode() int {
+	return r.exitCode
+}
+
+func pluralize(word string, count int) string {
+	if count == 1 {
+		return word
+	}
+	return word + "s"
+}
diff --git a/internal/cadence/linter.go b/internal/cadence/linter.go
new file mode 100644
index 00000000..0a54b970
--- /dev/null
+++ b/internal/cadence/linter.go
@@ -0,0 +1,352 @@
+/*
+ * Flow CLI
+ *
+ * Copyright 2019 Dapper Labs, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package cadence
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"errors"
+
+	cdclint "github.com/onflow/cadence-tools/lint"
+	cdctests "github.com/onflow/cadence-tools/test/helpers"
+	"github.com/onflow/cadence/runtime/ast"
+	"github.com/onflow/cadence/runtime/common"
+	cdcerrors "github.com/onflow/cadence/runtime/errors"
+	"github.com/onflow/cadence/runtime/parser"
+	"github.com/onflow/cadence/runtime/sema"
+	"github.com/onflow/cadence/runtime/stdlib"
+	"github.com/onflow/cadence/tools/analysis"
+	"github.com/onflow/flowkit/v2"
+	"golang.org/x/exp/maps"
+)
+
+type linter struct {
+	checkers              map[string]*sema.Checker
+	state                 *flowkit.State
+	checkerStandardConfig *sema.Config
+	checkerScriptConfig   *sema.Config
+}
+
+type positionedError interface {
+	error
+	ast.HasPosition
+}
+
+// Error diagnostic categories
+const (
+	SemanticErrorCategory = "semantic-error"
+	SyntaxErrorCategory   = "syntax-error"
+	ErrorCategory         = "error"
+)
+
+var analyzers = maps.Values(cdclint.Analyzers)
+
+func newLinter(state *flowkit.State) *linter {
+	l := &linter{
+		checkers: make(map[string]*sema.Checker),
+		state:    state,
+	}
+
+	// Create checker configs for both standard and script
+	// Scripts have a different stdlib than contracts and transactions
+	l.checkerStandardConfig = l.newCheckerConfig(newStandardLibrary())
+	l.checkerScriptConfig = l.newCheckerConfig(newScriptStandardLibrary())
+
+	return l
+}
+
+func (l *linter) lintFile(
+	filePath string,
+) (
+	[]analysis.Diagnostic,
+	error,
+) {
+	diagnostics := make([]analysis.Diagnostic, 0)
+	location := common.StringLocation(filePath)
+
+	code, err := l.state.ReadFile(filePath)
+	if err != nil {
+		return nil, err
+	}
+	codeStr := string(code)
+
+	// Parse program & convert any parsing errors to diagnostics
+	program, parseProgramErr := parser.ParseProgram(nil, code, parser.Config{})
+	if parseProgramErr != nil {
+		var parserErr *parser.Error
+		if !errors.As(parseProgramErr, &parserErr) {
+			return nil, fmt.Errorf("could not process parsing error: %s", parseProgramErr)
+		}
+
+		checkerDiagnostics, err := getDiagnosticsFromParentError(parserErr, location, codeStr)
+		if err != nil {
+			return nil, err
+		}
+
+		diagnostics = append(diagnostics, checkerDiagnostics...)
+	}
+
+	// If the program is nil, nothing can be checked & analyzed so return early
+	if program == nil {
+		return diagnostics, nil
+	}
+
+	// Create checker based on program type
+	checker, err := sema.NewChecker(
+		program,
+		location,
+		nil,
+		l.decideCheckerConfig(program),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	// Check the program & convert any checking errors to diagnostics
+	checkProgramErr := checker.Check()
+	if checkProgramErr != nil {
+		var checkerErr *sema.CheckerError
+		if !errors.As(checkProgramErr, &checkerErr) {
+			return nil, fmt.Errorf("could not process checking error: %s", checkProgramErr)
+		}
+
+		checkerDiagnostics, err := getDiagnosticsFromParentError(checkerErr, location, codeStr)
+		if err != nil {
+			return nil, err
+		}
+
+		diagnostics = append(diagnostics, checkerDiagnostics...)
+	}
+
+	// Run analysis on the program
+	analysisProgram := analysis.Program{
+		Program:  program,
+		Checker:  checker,
+		Location: checker.Location,
+		Code:     []byte(code),
+	}
+	report := func(diagnostic analysis.Diagnostic) {
+		diagnostics = append(diagnostics, diagnostic)
+	}
+	analysisProgram.Run(analyzers, report)
+
+	return diagnostics, nil
+}
+
+// Create a new checker config with the given standard library
+func (l *linter) newCheckerConfig(lib standardLibrary) *sema.Config {
+	return &sema.Config{
+		BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation {
+			return lib.baseValueActivation
+		},
+		AccessCheckMode:            sema.AccessCheckModeStrict,
+		PositionInfoEnabled:        true, // Must be enabled for linters
+		ExtendedElaborationEnabled: true, // Must be enabled for linters
+		ImportHandler:              l.handleImport,
+		SuggestionsEnabled:         true, // Must be enabled to offer semantic suggestions
+		AttachmentsEnabled:         true,
+	}
+}
+
+// Choose the checker config based on the assumed type of the program
+func (l *linter) decideCheckerConfig(program *ast.Program) *sema.Config {
+	if program.SoleTransactionDeclaration() != nil || program.SoleContractDeclaration() != nil {
+		return l.checkerStandardConfig
+	}
+
+	return l.checkerScriptConfig
+}
+
+// Resolve any imports found in the program while checking
+func (l *linter) handleImport(
+	checker *sema.Checker,
+	importedLocation common.Location,
+	_ ast.Range,
+) (
+	sema.Import,
+	error,
+) {
+	switch importedLocation {
+	case stdlib.CryptoCheckerLocation:
+		cryptoChecker := stdlib.CryptoChecker()
+		return sema.ElaborationImport{
+			Elaboration: cryptoChecker.Elaboration,
+		}, nil
+	case stdlib.TestContractLocation:
+		testChecker := stdlib.GetTestContractType().Checker
+		return sema.ElaborationImport{
+			Elaboration: testChecker.Elaboration,
+		}, nil
+	case cdctests.BlockchainHelpersLocation:
+		helpersChecker := cdctests.BlockchainHelpersChecker()
+		return sema.ElaborationImport{
+			Elaboration: helpersChecker.Elaboration,
+		}, nil
+	default:
+		filepath, err := l.resolveImportFilepath(importedLocation, checker.Location)
+		if err != nil {
+			return nil, err
+		}
+
+		importedChecker, ok := l.checkers[filepath]
+		if !ok {
+			code, err := l.state.ReadFile(filepath)
+			if err != nil {
+				return nil, err
+			}
+
+			importedProgram, err := parser.ParseProgram(nil, code, parser.Config{})
+
+			if err != nil {
+				return nil, err
+			}
+			if importedProgram == nil {
+				return nil, &sema.CheckerError{
+					Errors: []error{fmt.Errorf("cannot import %s", importedLocation)},
+				}
+			}
+
+			importedChecker, err = checker.SubChecker(importedProgram, importedLocation)
+			if err != nil {
+				return nil, err
+			}
+
+			l.checkers[filepath] = importedChecker
+			err = importedChecker.Check()
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		return sema.ElaborationImport{
+			Elaboration: importedChecker.Elaboration,
+		}, nil
+	}
+}
+
+func (l *linter) resolveImportFilepath(
+	location common.Location,
+	parentLocation common.Location,
+) (
+	string,
+	error,
+) {
+	switch location := location.(type) {
+	case common.StringLocation:
+		// If the location is not a cadence file try getting the code by identifier
+		if !strings.Contains(location.String(), ".cdc") {
+			contract, err := l.state.Contracts().ByName(location.String())
+			if err != nil {
+				return "", err
+			}
+
+			return contract.Location, nil
+		}
+
+		// If the location is a cadence file, resolve relative to the parent location
+		parentPath := ""
+		if parentLocation != nil {
+			parentPath = parentLocation.String()
+		}
+
+		resolvedPath := filepath.Join(filepath.Dir(parentPath), location.String())
+		return resolvedPath, nil
+	default:
+		return "", fmt.Errorf("unsupported location: %T", location)
+	}
+}
+
+// helpers
+
+func getDiagnosticsFromParentError(err cdcerrors.ParentError, location common.Location, code string) ([]analysis.Diagnostic, error) {
+	diagnostics := make([]analysis.Diagnostic, 0)
+
+	for _, childErr := range err.ChildErrors() {
+		var positionedErr positionedError
+		if !errors.As(childErr, &positionedErr) {
+			return nil, fmt.Errorf("could not process error: %s", childErr)
+		}
+
+		diagnostic := convertPositionedErrorToDiagnostic(positionedErr, location, code)
+		if diagnostic == nil {
+			continue
+		}
+
+		diagnostics = append(diagnostics, *diagnostic)
+	}
+
+	return diagnostics, nil
+}
+
+func convertPositionedErrorToDiagnostic(
+	err positionedError,
+	location common.Location,
+	code string,
+) *analysis.Diagnostic {
+	message := err.Error()
+	startPosition := err.StartPosition()
+	endPosition := err.EndPosition(nil)
+
+	var secondaryMessage string
+	var secondaryErr cdcerrors.SecondaryError
+	if errors.As(err, &secondaryErr) {
+		secondaryMessage = secondaryErr.SecondaryError()
+	}
+
+	var category string
+	var semanticErr sema.SemanticError
+	var syntaxErr *parser.SyntaxError
+	var syntaxErrWithSuggestedReplacement *parser.SyntaxErrorWithSuggestedReplacement
+	switch {
+	case errors.As(err, &semanticErr):
+		category = SemanticErrorCategory
+	case errors.As(err, &syntaxErr):
+		category = SyntaxErrorCategory
+	case errors.As(err, &syntaxErrWithSuggestedReplacement):
+		category = SyntaxErrorCategory
+	default:
+		category = ErrorCategory
+	}
+
+	var suggestedFixes []cdcerrors.SuggestedFix[ast.TextEdit]
+	var errWithFixes cdcerrors.HasSuggestedFixes[ast.TextEdit]
+	if errors.As(err, &errWithFixes) {
+		suggestedFixes = errWithFixes.SuggestFixes(code)
+	}
+
+	diagnostic := analysis.Diagnostic{
+		Location:         location,
+		Category:         category,
+		Message:          message,
+		SecondaryMessage: secondaryMessage,
+		Range: ast.Range{
+			StartPos: startPosition,
+			EndPos:   endPosition,
+		},
+		SuggestedFixes: suggestedFixes,
+	}
+
+	return &diagnostic
+}
+
+func isErrorDiagnostic(diagnostic analysis.Diagnostic) bool {
+	return diagnostic.Category == ErrorCategory || diagnostic.Category == SemanticErrorCategory || diagnostic.Category == SyntaxErrorCategory
+}
diff --git a/internal/cadence/stdlib.go b/internal/cadence/stdlib.go
new file mode 100644
index 00000000..15c05388
--- /dev/null
+++ b/internal/cadence/stdlib.go
@@ -0,0 +1,304 @@
+/*
+ * Cadence - The resource-oriented smart contract programming language
+ *
+ * Copyright 2019-2022 Dapper Labs, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// NOTE: This file is a copy of the file https://github.com/onflow/cadence-tools/blob/master/languageserver/server/stdlib.go
+
+package cadence
+
+import (
+	"github.com/onflow/cadence/runtime/common"
+	"github.com/onflow/cadence/runtime/errors"
+	"github.com/onflow/cadence/runtime/interpreter"
+	"github.com/onflow/cadence/runtime/sema"
+	"github.com/onflow/cadence/runtime/stdlib"
+)
+
+type standardLibrary struct {
+	baseValueActivation *sema.VariableActivation
+}
+
+var _ stdlib.StandardLibraryHandler = standardLibrary{}
+
+func (standardLibrary) ProgramLog(_ string, _ interpreter.LocationRange) error {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) UnsafeRandom() (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetBlockAtHeight(_ uint64) (stdlib.Block, bool, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetCurrentBlockHeight() (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetAccountBalance(_ common.Address) (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetAccountAvailableBalance(_ common.Address) (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) CommitStorageTemporarily(_ *interpreter.Interpreter) error {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetStorageUsed(_ common.Address) (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetStorageCapacity(_ common.Address) (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetAccountKey(_ common.Address, _ int) (*stdlib.AccountKey, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetAccountContractNames(_ common.Address) ([]string, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) GetAccountContractCode(_ common.AddressLocation) ([]byte, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) EmitEvent(
+	_ *interpreter.Interpreter,
+	_ *sema.CompositeType,
+	_ []interpreter.Value,
+	_ interpreter.LocationRange,
+) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) AddEncodedAccountKey(_ common.Address, _ []byte) error {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) RevokeEncodedAccountKey(_ common.Address, _ int) ([]byte, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) AddAccountKey(
+	_ common.Address,
+	_ *stdlib.PublicKey,
+	_ sema.HashAlgorithm,
+	_ int,
+) (
+	*stdlib.AccountKey,
+	error,
+) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) RevokeAccountKey(_ common.Address, _ int) (*stdlib.AccountKey, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) ParseAndCheckProgram(_ []byte, _ common.Location, _ bool) (*interpreter.Program, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) UpdateAccountContractCode(_ common.AddressLocation, _ []byte) error {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) RecordContractUpdate(_ common.AddressLocation, _ *interpreter.CompositeValue) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) ContractUpdateRecorded(_ common.AddressLocation) bool {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) InterpretContract(
+	_ common.AddressLocation,
+	_ *interpreter.Program,
+	_ string,
+	_ stdlib.DeployedContractConstructorInvocation,
+) (*interpreter.CompositeValue, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) TemporarilyRecordCode(_ common.AddressLocation, _ []byte) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) RemoveAccountContractCode(_ common.AddressLocation) error {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) RecordContractRemoval(_ common.AddressLocation) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) CreateAccount(_ common.Address) (address common.Address, err error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) ValidatePublicKey(_ *stdlib.PublicKey) error {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) VerifySignature(
+	_ []byte,
+	_ string,
+	_ []byte,
+	_ []byte,
+	_ sema.SignatureAlgorithm,
+	_ sema.HashAlgorithm,
+) (bool, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) BLSVerifyPOP(_ *stdlib.PublicKey, _ []byte) (bool, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) Hash(_ []byte, _ string, _ sema.HashAlgorithm) ([]byte, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) AccountKeysCount(_ common.Address) (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) BLSAggregatePublicKeys(_ []*stdlib.PublicKey) (*stdlib.PublicKey, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) BLSAggregateSignatures(_ [][]byte) ([]byte, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (l standardLibrary) GenerateAccountID(_ common.Address) (uint64, error) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (l standardLibrary) ReadRandom(_ []byte) error {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) StartContractAddition(_ common.AddressLocation) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) EndContractAddition(_ common.AddressLocation) {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func (standardLibrary) IsContractBeingAdded(_ common.AddressLocation) bool {
+	// Implementation should never be called,
+	// only its definition is used for type-checking
+	panic(errors.NewUnreachableError())
+}
+
+func newStandardLibrary() (result standardLibrary) {
+	result.baseValueActivation = sema.NewVariableActivation(sema.BaseValueActivation)
+	for _, valueDeclaration := range stdlib.DefaultStandardLibraryValues(result) {
+		result.baseValueActivation.DeclareValue(valueDeclaration)
+	}
+	return
+}
+
+func newScriptStandardLibrary() (result standardLibrary) {
+	result.baseValueActivation = sema.NewVariableActivation(sema.BaseValueActivation)
+	for _, declaration := range stdlib.DefaultScriptStandardLibraryValues(result) {
+		result.baseValueActivation.DeclareValue(declaration)
+	}
+	return
+}
diff --git a/internal/command/command.go b/internal/command/command.go
index 891d133d..ad446abb 100644
--- a/internal/command/command.go
+++ b/internal/command/command.go
@@ -67,11 +67,10 @@ type RunWithState func(
 ) (Result, error)
 
 type Command struct {
-	Cmd    *cobra.Command
-	Flags  any
-	Run    run
-	RunS   RunWithState
-	Status *int
+	Cmd   *cobra.Command
+	Flags any
+	Run   run
+	RunS  RunWithState
 }
 
 const (
@@ -163,6 +162,13 @@ func (c Command) AddToParent(parent *cobra.Command) {
 		handleError("Output Error", err)
 
 		wg.Wait()
+
+		// exit with code if result has it
+		exitCode := 0
+		if res, ok := result.(ResultWithExitCode); ok {
+			exitCode = res.ExitCode()
+		}
+		os.Exit(exitCode)
 	}
 
 	bindFlags(c)
diff --git a/internal/command/result.go b/internal/command/result.go
index ed448359..020afa6a 100644
--- a/internal/command/result.go
+++ b/internal/command/result.go
@@ -44,6 +44,11 @@ type Result interface {
 	JSON() any
 }
 
+type ResultWithExitCode interface {
+	Result
+	ExitCode() int
+}
+
 // ContainsFlag checks if output flag is present for the provided field.
 func ContainsFlag(flags []string, field string) bool {
 	for _, n := range flags {


Test patch:
diff --git a/internal/cadence/lint_test.go b/internal/cadence/lint_test.go
new file mode 100644
index 00000000..81c2585a
--- /dev/null
+++ b/internal/cadence/lint_test.go
@@ -0,0 +1,324 @@
+/*
+ * Flow CLI
+ *
+ * Copyright 2019 Dapper Labs, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package cadence
+
+import (
+	"testing"
+
+	"github.com/onflow/cadence/runtime/ast"
+	"github.com/onflow/cadence/runtime/common"
+	"github.com/onflow/cadence/tools/analysis"
+	"github.com/onflow/flow-go-sdk/crypto"
+	"github.com/onflow/flowkit/v2"
+	"github.com/onflow/flowkit/v2/config"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_Lint(t *testing.T) {
+	state := setupMockState(t)
+
+	// Test this to make sure that lintResult exit codes are actually propogated to CLI result
+	t.Run("results.exitCode exported via result.ExitCode()", func(t *testing.T) {
+		results := lintResult{
+			exitCode: 999,
+		}
+		require.Equal(t, 999, results.ExitCode())
+	})
+
+	t.Run("lints file with no issues", func(t *testing.T) {
+		results, error := lintFiles(state, "NoError.cdc")
+		require.NoError(t, error)
+
+		require.Equal(t, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath:    "NoError.cdc",
+					Diagnostics: []analysis.Diagnostic{},
+				},
+			},
+			exitCode: 0,
+		}, results)
+	})
+
+	t.Run("lints file with import", func(t *testing.T) {
+		results, error := lintFiles(state, "foo/WithImports.cdc")
+		require.NoError(t, error)
+
+		// Should not have results for imported file, only for the file being linted
+		require.Equal(t, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath:    "foo/WithImports.cdc",
+					Diagnostics: []analysis.Diagnostic{},
+				},
+			},
+			exitCode: 0,
+		}, results)
+	})
+
+	t.Run("lints multiple files", func(t *testing.T) {
+		results, error := lintFiles(state, "NoError.cdc", "foo/WithImports.cdc")
+		require.NoError(t, error)
+
+		require.Equal(t, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath:    "NoError.cdc",
+					Diagnostics: []analysis.Diagnostic{},
+				},
+				{
+					FilePath:    "foo/WithImports.cdc",
+					Diagnostics: []analysis.Diagnostic{},
+				},
+			},
+			exitCode: 0,
+		}, results)
+	})
+
+	t.Run("lints file with warning", func(t *testing.T) {
+		results, error := lintFiles(state, "LintWarning.cdc")
+		require.NoError(t, error)
+
+		require.Equal(t, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath: "LintWarning.cdc",
+					Diagnostics: []analysis.Diagnostic{
+						{
+							Category: "removal-hint",
+							Message:  "unnecessary force operator",
+							Location: common.StringLocation("LintWarning.cdc"),
+							Range: ast.Range{
+								StartPos: ast.Position{Line: 4, Column: 11, Offset: 59},
+								EndPos:   ast.Position{Line: 4, Column: 12, Offset: 60},
+							},
+						},
+					},
+				},
+			},
+			exitCode: 0,
+		}, results)
+	})
+
+	t.Run("lints file with error", func(t *testing.T) {
+		results, error := lintFiles(state, "LintError.cdc")
+		require.NoError(t, error)
+
+		require.Equal(t, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath: "LintError.cdc",
+					Diagnostics: []analysis.Diagnostic{
+						{
+							Category: "removal-hint",
+							Message:  "unnecessary force operator",
+							Location: common.StringLocation("LintError.cdc"),
+							Range: ast.Range{
+								StartPos: ast.Position{Line: 4, Column: 11, Offset: 57},
+								EndPos:   ast.Position{Line: 4, Column: 12, Offset: 58},
+							},
+						},
+						{
+							Category:         "semantic-error",
+							Message:          "cannot find variable in this scope: `qqq`",
+							SecondaryMessage: "not found in this scope",
+							Location:         common.StringLocation("LintError.cdc"),
+							Range: ast.Range{
+								StartPos: ast.Position{Line: 5, Column: 3, Offset: 63},
+								EndPos:   ast.Position{Line: 5, Column: 5, Offset: 65},
+							},
+						},
+					},
+				},
+			},
+			exitCode: 1,
+		}, results)
+	})
+
+	t.Run("linter resolves imports from flowkit state", func(t *testing.T) {
+		results, error := lintFiles(state, "WithFlowkitImport.cdc")
+		require.NoError(t, error)
+
+		require.Equal(t, results, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath:    "WithFlowkitImport.cdc",
+					Diagnostics: []analysis.Diagnostic{},
+				},
+			},
+			exitCode: 0,
+		})
+	})
+
+	t.Run("resolves stdlib imports contracts", func(t *testing.T) {
+		results, error := lintFiles(state, "StdlibImportsContract.cdc")
+		require.NoError(t, error)
+
+		// Expects an error because getAuthAccount is only available in scripts
+		require.Equal(t, results, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath: "StdlibImportsContract.cdc",
+					Diagnostics: []analysis.Diagnostic{
+						{
+							Category:         "semantic-error",
+							Message:          "cannot find variable in this scope: `getAuthAccount`",
+							SecondaryMessage: "not found in this scope",
+							Location:         common.StringLocation("StdlibImportsContract.cdc"),
+							Range: ast.Range{
+								StartPos: ast.Position{Line: 7, Column: 13, Offset: 114},
+								EndPos:   ast.Position{Line: 7, Column: 26, Offset: 127},
+							},
+						},
+					},
+				},
+			},
+			exitCode: 1,
+		})
+	})
+
+	t.Run("resolves stdlib imports transactions", func(t *testing.T) {
+		results, error := lintFiles(state, "StdlibImportsTransaction.cdc")
+		require.NoError(t, error)
+
+		// Expects an error because getAuthAccount is only available in scripts
+		require.Equal(t, results, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath: "StdlibImportsTransaction.cdc",
+					Diagnostics: []analysis.Diagnostic{
+						{
+							Category:         "semantic-error",
+							Message:          "cannot find variable in this scope: `getAuthAccount`",
+							SecondaryMessage: "not found in this scope",
+							Location:         common.StringLocation("StdlibImportsTransaction.cdc"),
+							Range: ast.Range{
+								StartPos: ast.Position{Line: 7, Column: 13, Offset: 113},
+								EndPos:   ast.Position{Line: 7, Column: 26, Offset: 126},
+							},
+						},
+					},
+				},
+			},
+			exitCode: 1,
+		})
+	})
+
+	t.Run("resolves stdlib imports scripts", func(t *testing.T) {
+		results, error := lintFiles(state, "StdlibImportsScript.cdc")
+		require.NoError(t, error)
+
+		require.Equal(t, results, &lintResult{
+			Results: []fileResult{
+				{
+					FilePath:    "StdlibImportsScript.cdc",
+					Diagnostics: []analysis.Diagnostic{},
+				},
+			},
+			exitCode: 0,
+		})
+	})
+}
+
+func setupMockState(t *testing.T) *flowkit.State {
+	// Mock file system
+	mockFs := afero.NewMemMapFs()
+	_ = afero.WriteFile(mockFs, "NoError.cdc", []byte(`
+	access(all) contract NoError {
+		access(all) fun test() {}
+		init() {}
+	}
+	`), 0644)
+	_ = afero.WriteFile(mockFs, "foo/WithImports.cdc", []byte(`
+	import "../NoError.cdc"
+	access(all) contract WithImports {
+		init() {}
+	}
+	`), 0644)
+	_ = afero.WriteFile(mockFs, "WithFlowkitImport.cdc", []byte(`
+	import "NoError"
+	access(all) contract WithFlowkitImport {
+		init() {
+			let foo = NoError.getType()
+		}
+	}
+	`), 0644)
+	_ = afero.WriteFile(mockFs, "LintWarning.cdc", []byte(`
+	access(all) contract LintWarning {
+		init() {
+			let x = 1!
+		}
+	}`), 0644)
+	_ = afero.WriteFile(mockFs, "LintError.cdc", []byte(`
+	access(all) contract LintError {
+		init() {
+			let x = 1!
+			qqq
+		}
+	}`), 0644)
+	_ = afero.WriteFile(mockFs, "CadenceV1Error.cdc", []byte(`
+	access(all) contract CadenceV1Error {
+		init() {
+			let test: PublicAccount = self.account
+		}
+	}`), 0644)
+	_ = afero.WriteFile(mockFs, "StdlibImportsContract.cdc", []byte(`
+	import Crypto
+	import Test
+	import BlockchainHelpers
+	access(all) contract WithImports{
+		init() {
+			let foo = getAuthAccount<&Account>(0x01)
+			log(RLP.getType())
+		}
+	}
+	`), 0644)
+	_ = afero.WriteFile(mockFs, "StdlibImportsTransaction.cdc", []byte(`
+	import Crypto
+	import Test
+	import BlockchainHelpers
+	transaction {
+		prepare(signer: &Account) {
+			let foo = getAuthAccount<&Account>(0x01)
+			log(RLP.getType())
+		}
+	}
+	`), 0644)
+	_ = afero.WriteFile(mockFs, "StdlibImportsScript.cdc", []byte(`
+	import Crypto
+	import Test
+	import BlockchainHelpers
+	access(all) fun main(): Void {
+		let foo = getAuthAccount<&Account>(0x01)
+		log(RLP.getType())
+	}`), 0644)
+
+	rw := afero.Afero{Fs: mockFs}
+	state, err := flowkit.Init(rw, crypto.ECDSA_P256, crypto.SHA3_256)
+	require.NoError(t, err)
+
+	// Mock flowkit contracts
+	state.Contracts().AddOrUpdate(config.Contract{
+		Name:     "NoError",
+		Location: "NoError.cdc",
+	})
+
+	return state
+}
diff --git a/internal/test/test.go b/internal/test/test.go
index 3d594e8f..84f228bc 100644
--- a/internal/test/test.go
+++ b/internal/test/test.go
@@ -71,8 +71,6 @@ type flagsTests struct {
 
 var testFlags = flagsTests{}
 
-var status = 0
-
 var TestCommand = &command.Command{
 	Cmd: &cobra.Command{
 		Use:     "test <filename>",
@@ -81,9 +79,8 @@ var TestCommand = &command.Command{
 		Args:    cobra.MinimumNArgs(1),
 		GroupID: "tools",
 	},
-	Flags:  &testFlags,
-	RunS:   run,
-	Status: &status,
+	Flags: &testFlags,
+	RunS:  run,
 }
 
 func run(
@@ -187,6 +184,7 @@ func testCode(
 	}
 
 	testResults := make(map[string]cdcTests.Results, 0)
+	exitCode := 0
 	for scriptPath, code := range testFiles {
 		runner := runner.
 			WithImportResolver(importResolver(scriptPath, state)).
@@ -220,7 +218,7 @@ func testCode(
 
 		for _, result := range testResults[scriptPath] {
 			if result.Error != nil {
-				status = 1
+				exitCode = 1
 				break
 			}
 		}
@@ -230,6 +228,7 @@ func testCode(
 		Results:        testResults,
 		CoverageReport: coverageReport,
 		RandomSeed:     seed,
+		exitCode:       exitCode,
 	}, nil
 }
 
@@ -302,9 +301,10 @@ type result struct {
 	Results        map[string]cdcTests.Results
 	CoverageReport *runtime.CoverageReport
 	RandomSeed     int64
+	exitCode       int
 }
 
-var _ command.Result = &result{}
+var _ command.ResultWithExitCode = &result{}
 
 func (r *result) JSON() any {
 	results := make(map[string]map[string]string, len(r.Results))
@@ -376,3 +376,7 @@ func (r *result) Oneliner() string {
 
 	return builder.String()
 }
+
+func (r *result) ExitCode() int {
+	return r.exitCode
+}