Skip to content

Commit f78a42d

Browse files
committed
Add cadence_lint tool using AST-based cadence-tools analyzers
1 parent 73c2ffc commit f78a42d

File tree

4 files changed

+292
-0
lines changed

4 files changed

+292
-0
lines changed

internal/mcp/lint.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
* Flow CLI
3+
*
4+
* Copyright Flow Foundation
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package mcp
20+
21+
import (
22+
"errors"
23+
"fmt"
24+
"strings"
25+
26+
cdclint "github.com/onflow/cadence-tools/lint"
27+
cdctests "github.com/onflow/cadence-tools/test/helpers"
28+
"github.com/onflow/cadence/ast"
29+
"github.com/onflow/cadence/common"
30+
cdcerrors "github.com/onflow/cadence/errors"
31+
"github.com/onflow/cadence/parser"
32+
"github.com/onflow/cadence/sema"
33+
"github.com/onflow/cadence/stdlib"
34+
"github.com/onflow/cadence/tools/analysis"
35+
"golang.org/x/exp/maps"
36+
37+
"github.com/onflow/flow-cli/internal/util"
38+
)
39+
40+
// lintCode runs all registered cadence-tools lint analyzers on the given code.
41+
// Returns analysis diagnostics (not LSP protocol diagnostics).
42+
func lintCode(code string) ([]analysis.Diagnostic, error) {
43+
location := common.StringLocation("code.cdc")
44+
codeBytes := []byte(code)
45+
46+
// Parse
47+
program, parseErr := parser.ParseProgram(nil, codeBytes, parser.Config{})
48+
if parseErr != nil {
49+
var parentErr cdcerrors.ParentError
50+
if errors.As(parseErr, &parentErr) {
51+
return collectPositionedErrors(parentErr, location), nil
52+
}
53+
return nil, fmt.Errorf("parse error: %w", parseErr)
54+
}
55+
if program == nil {
56+
return nil, nil
57+
}
58+
59+
// Choose standard library based on program type
60+
var stdLib *util.StandardLibrary
61+
if program.SoleTransactionDeclaration() != nil || program.SoleContractDeclaration() != nil {
62+
stdLib = util.NewStandardLibrary()
63+
} else {
64+
stdLib = util.NewScriptStandardLibrary()
65+
}
66+
67+
checkerConfig := &sema.Config{
68+
BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation {
69+
return stdLib.BaseValueActivation
70+
},
71+
AccessCheckMode: sema.AccessCheckModeNotSpecifiedUnrestricted,
72+
PositionInfoEnabled: true,
73+
ExtendedElaborationEnabled: true,
74+
ImportHandler: lintImportHandler,
75+
}
76+
77+
// Type check
78+
checker, err := sema.NewChecker(program, location, nil, checkerConfig)
79+
if err != nil {
80+
return nil, fmt.Errorf("checker creation error: %w", err)
81+
}
82+
83+
var diagnostics []analysis.Diagnostic
84+
85+
checkErr := checker.Check()
86+
if checkErr != nil {
87+
var checkerErr *sema.CheckerError
88+
if errors.As(checkErr, &checkerErr) {
89+
diagnostics = append(diagnostics, collectPositionedErrors(checkerErr, location)...)
90+
}
91+
}
92+
93+
// Run lint analyzers
94+
analyzers := maps.Values(cdclint.Analyzers)
95+
analysisProgram := analysis.Program{
96+
Program: program,
97+
Checker: checker,
98+
Location: location,
99+
Code: codeBytes,
100+
}
101+
analysisProgram.Run(analyzers, func(d analysis.Diagnostic) {
102+
diagnostics = append(diagnostics, d)
103+
})
104+
105+
return diagnostics, nil
106+
}
107+
108+
// lintImportHandler resolves standard library imports for lint analysis.
109+
func lintImportHandler(
110+
checker *sema.Checker,
111+
importedLocation common.Location,
112+
_ ast.Range,
113+
) (sema.Import, error) {
114+
switch importedLocation {
115+
case stdlib.TestContractLocation:
116+
return sema.ElaborationImport{
117+
Elaboration: stdlib.GetTestContractType().Checker.Elaboration,
118+
}, nil
119+
case cdctests.BlockchainHelpersLocation:
120+
return sema.ElaborationImport{
121+
Elaboration: cdctests.BlockchainHelpersChecker().Elaboration,
122+
}, nil
123+
default:
124+
return nil, fmt.Errorf("cannot resolve import: %s", importedLocation)
125+
}
126+
}
127+
128+
type positionedError interface {
129+
error
130+
ast.HasPosition
131+
}
132+
133+
// collectPositionedErrors extracts positioned errors from a parent error.
134+
func collectPositionedErrors(err cdcerrors.ParentError, location common.Location) []analysis.Diagnostic {
135+
var diagnostics []analysis.Diagnostic
136+
for _, childErr := range err.ChildErrors() {
137+
var posErr positionedError
138+
if !errors.As(childErr, &posErr) {
139+
continue
140+
}
141+
diagnostics = append(diagnostics, analysis.Diagnostic{
142+
Location: location,
143+
Category: "error",
144+
Message: posErr.Error(),
145+
Range: ast.Range{
146+
StartPos: posErr.StartPosition(),
147+
EndPos: posErr.EndPosition(nil),
148+
},
149+
})
150+
}
151+
return diagnostics
152+
}
153+
154+
// formatLintDiagnostics formats analysis diagnostics as human-readable text.
155+
func formatLintDiagnostics(diagnostics []analysis.Diagnostic) string {
156+
if len(diagnostics) == 0 {
157+
return "Lint passed — no issues found."
158+
}
159+
160+
var b strings.Builder
161+
errors := 0
162+
warnings := 0
163+
for _, d := range diagnostics {
164+
severity := "warning"
165+
if d.Category == "error" || d.Category == "semantic-error" || d.Category == "syntax-error" {
166+
severity = "error"
167+
errors++
168+
} else {
169+
warnings++
170+
}
171+
fmt.Fprintf(&b, "[%s] line %d:%d (%s): %s\n",
172+
severity,
173+
d.Range.StartPos.Line,
174+
d.Range.StartPos.Column,
175+
d.Category,
176+
d.Message,
177+
)
178+
}
179+
fmt.Fprintf(&b, "\n%d error(s), %d warning(s)\n", errors, warnings)
180+
return b.String()
181+
}

internal/mcp/lint_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Flow CLI
3+
*
4+
* Copyright Flow Foundation
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package mcp
20+
21+
import (
22+
"testing"
23+
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
func TestLintCode_ValidCode(t *testing.T) {
29+
t.Parallel()
30+
31+
code := `access(all) fun main(): String { return "hello" }`
32+
diags, err := lintCode(code)
33+
require.NoError(t, err)
34+
// Valid code should only have lint hints, no errors
35+
for _, d := range diags {
36+
assert.NotEqual(t, "error", d.Category)
37+
assert.NotEqual(t, "semantic-error", d.Category)
38+
assert.NotEqual(t, "syntax-error", d.Category)
39+
}
40+
}
41+
42+
func TestLintCode_SyntaxError(t *testing.T) {
43+
t.Parallel()
44+
45+
code := `access(all) fun main( {`
46+
diags, err := lintCode(code)
47+
require.NoError(t, err)
48+
assert.NotEmpty(t, diags, "syntax errors should produce diagnostics")
49+
}
50+
51+
func TestLintCode_TypeError(t *testing.T) {
52+
t.Parallel()
53+
54+
code := `access(all) fun main(): String { return 42 }`
55+
diags, err := lintCode(code)
56+
require.NoError(t, err)
57+
assert.NotEmpty(t, diags, "type errors should produce diagnostics")
58+
59+
hasError := false
60+
for _, d := range diags {
61+
if d.Category == "error" || d.Category == "semantic-error" {
62+
hasError = true
63+
}
64+
}
65+
assert.True(t, hasError, "should have error-level diagnostics")
66+
}
67+
68+
func TestLintCode_AnalyzersRun(t *testing.T) {
69+
t.Parallel()
70+
71+
// Redundant cast should be detected by lint analyzers
72+
code := `
73+
access(all) fun main(): Int {
74+
let x: Int = 42
75+
return x as Int
76+
}
77+
`
78+
diags, err := lintCode(code)
79+
require.NoError(t, err)
80+
// Should have at least one lint diagnostic (redundant cast or similar)
81+
assert.NotEmpty(t, diags, "lint analyzers should produce diagnostics for redundant cast")
82+
}
83+
84+
func TestFormatLintDiagnostics_Empty(t *testing.T) {
85+
t.Parallel()
86+
87+
result := formatLintDiagnostics(nil)
88+
assert.Contains(t, result, "Lint passed")
89+
}

internal/mcp/mcp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Available tools:
6262
cadence_completion Get completions at a position
6363
get_contract_source Fetch on-chain contract manifest
6464
get_contract_code Fetch contract source code from an address
65+
cadence_lint Run Cadence lint analyzers (AST-based)
6566
cadence_code_review Review Cadence code for common issues
6667
cadence_execute_script Execute a read-only Cadence script on-chain`,
6768
Run: runMCP,

internal/mcp/tools.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) {
129129
mctx.getContractCode,
130130
)
131131

132+
s.AddTool(
133+
mcplib.NewTool("cadence_lint",
134+
mcplib.WithDescription("Run Cadence lint analyzers on code. Detects common issues like unnecessary casts, deprecated patterns, unused variables, and more using AST-based analysis."),
135+
mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code to lint")),
136+
),
137+
mctx.cadenceLint,
138+
)
139+
132140
s.AddTool(
133141
mcplib.NewTool("cadence_code_review",
134142
mcplib.WithDescription("Review Cadence code for common issues and anti-patterns."),
@@ -361,6 +369,19 @@ func (m *mcpContext) getContractCode(ctx context.Context, req mcplib.CallToolReq
361369
return mcplib.NewToolResultText(b.String()), nil
362370
}
363371

372+
func (m *mcpContext) cadenceLint(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
373+
code, err := resolveCode(req)
374+
if err != nil {
375+
return mcplib.NewToolResultError(err.Error()), nil
376+
}
377+
378+
diagnostics, err := lintCode(code)
379+
if err != nil {
380+
return mcplib.NewToolResultError(fmt.Sprintf("lint failed: %v", err)), nil
381+
}
382+
return mcplib.NewToolResultText(formatLintDiagnostics(diagnostics)), nil
383+
}
384+
364385
func (m *mcpContext) cadenceCodeReview(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
365386
code, err := resolveCode(req)
366387
if err != nil {

0 commit comments

Comments
 (0)