Repository: onflow/flow-cli
Pull request #1456 (2024-03-15 16:02:27)

Backport Add `flow cadence lint` command (#1448) to `master`
Backports lint command to master


Patch:
diff --git a/cmd/flow/main.go b/cmd/flow/main.go
index 9ccf802b..15af895c 100644
--- a/cmd/flow/main.go
+++ b/cmd/flow/main.go
@@ -156,8 +156,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 31485e4f..9e553ad7 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ 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 v0.42.9
 	github.com/onflow/cadence-tools/languageserver v0.33.3
@@ -154,7 +155,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
@@ -199,7 +199,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/sethvargo/go-retry v0.2.3 // indirect
diff --git a/go.sum b/go.sum
index 41f23621..ad79c017 100644
--- a/go.sum
+++ b/go.sum
@@ -1038,8 +1038,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 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/robertkrimen/otto v0.0.0-20170205013659-6a77b7cbc37d/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
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..29b7b506
--- /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"
+	"github.com/onflow/flowkit/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..b41459fa
--- /dev/null
+++ b/internal/cadence/linter.go
@@ -0,0 +1,342 @@
+/*
+ * 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"
+	"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,
+		Elaboration: checker.Elaboration,
+		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
+	switch {
+	case errors.As(err, &semanticErr):
+		category = SemanticErrorCategory
+	case errors.As(err, &syntaxErr):
+		category = SyntaxErrorCategory
+	default:
+		category = ErrorCategory
+	}
+
+	diagnostic := analysis.Diagnostic{
+		Location:         location,
+		Category:         category,
+		Message:          message,
+		SecondaryMessage: secondaryMessage,
+		Range: ast.Range{
+			StartPos: startPosition,
+			EndPos:   endPosition,
+		},
+	}
+
+	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..3f2c404d
--- /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) 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 47806655..822639dc 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 0e4ba3f3..b6576f39 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..99d025b5
--- /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"
+	"github.com/onflow/flowkit/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: 116},
+								EndPos:   ast.Position{Line: 7, Column: 26, Offset: 129},
+							},
+						},
+					},
+				},
+			},
+			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(0x01)
+			log(RLP.getType())
+		}
+	}
+	`), 0644)
+	_ = afero.WriteFile(mockFs, "StdlibImportsTransaction.cdc", []byte(`
+	import Crypto
+	import Test
+	import BlockchainHelpers
+	transaction {
+		prepare(signer: AuthAccount) {
+			let foo = getAuthAccount(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(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 3b1ad430..0d32560a 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
+}