Commit cd7e9d40 authored by Matthew Slipper's avatar Matthew Slipper Committed by GitHub

feat: Rewrite natspec checker in Go (#12191)

* feat: Rewrite natspec checker in Go

Rewrites the `semver-natspec-check-no-build` Just command in Go to reduce runtime. This PR reduces runtime for this check from ~1m30s to about 3 seconds post-compilation.

* remove old script

* add unit tests

* rename test

* review updates
parent 6ba2ac0d
...@@ -1458,6 +1458,9 @@ workflows: ...@@ -1458,6 +1458,9 @@ workflows:
- op-program - op-program
- op-service - op-service
- op-supervisor - op-supervisor
- go-test:
name: semver-natspec-tests
module: packages/contracts-bedrock/scripts/checks/semver-natspec
- go-test-kurtosis: - go-test-kurtosis:
name: op-chain-ops-integration name: op-chain-ops-integration
module: op-chain-ops module: op-chain-ops
......
...@@ -163,7 +163,7 @@ semver-diff-check: build semver-diff-check-no-build ...@@ -163,7 +163,7 @@ semver-diff-check: build semver-diff-check-no-build
# Checks that semver natspec is equal to the actual semver version. # Checks that semver natspec is equal to the actual semver version.
# Does not build contracts. # Does not build contracts.
semver-natspec-check-no-build: semver-natspec-check-no-build:
./scripts/checks/check-semver-natspec-match.sh go run ./scripts/checks/semver-natspec
# Checks that semver natspec is equal to the actual semver version. # Checks that semver natspec is equal to the actual semver version.
semver-natspec-check: build semver-natspec-check-no-build semver-natspec-check: build semver-natspec-check-no-build
......
#!/usr/bin/env bash
set -euo pipefail
# Grab the directory of the contracts-bedrock package
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
CONTRACTS_BASE=$(dirname "$(dirname "$SCRIPT_DIR")")
ARTIFACTS_DIR="$CONTRACTS_BASE/forge-artifacts"
CONTRACTS_DIR="$CONTRACTS_BASE/src"
# Load semver-utils
# shellcheck source=/dev/null
source "$SCRIPT_DIR/utils/semver-utils.sh"
# Flag to track if any errors are detected
has_errors=false
# Iterate through each artifact file
for artifact_file in "$ARTIFACTS_DIR"/**/*.json; do
# Get the contract name and find the corresponding source file
contract_name=$(basename "$artifact_file" .json)
contract_file=$(find "$CONTRACTS_DIR" -name "$contract_name.sol")
# Try to extract version as a constant
raw_metadata=$(jq -r '.rawMetadata' "$artifact_file")
artifact_version=$(echo "$raw_metadata" | jq -r '.output.devdoc.stateVariables.version."custom:semver"')
is_constant=true
if [ "$artifact_version" = "null" ]; then
# If not found as a constant, try to extract as a function
artifact_version=$(echo "$raw_metadata" | jq -r '.output.devdoc.methods."version()"."custom:semver"')
is_constant=false
fi
# If @custom:semver is not found in either location, skip this file
if [ "$artifact_version" = "null" ]; then
continue
fi
# If source file is not found, report an error
if [ -z "$contract_file" ]; then
echo "❌ $contract_name: Source file not found"
continue
fi
# Extract version from source based on whether it's a constant or function
if [ "$is_constant" = true ]; then
source_version=$(extract_constant_version "$contract_file")
else
source_version=$(extract_function_version "$contract_file")
fi
# If source version is not found, report an error
if [ "$source_version" = "" ]; then
echo "❌ Error: failed to find version string for $contract_name"
echo " this is probably a bug in check-contract-semver.sh"
echo " please report or fix the issue if possible"
has_errors=true
fi
# Compare versions
if [ "$source_version" != "$artifact_version" ]; then
echo "❌ Error: $contract_name has different semver in code and devdoc"
echo " Code: $source_version"
echo " Devdoc: $artifact_version"
has_errors=true
else
echo "✅ $contract_name: code: $source_version, devdoc: $artifact_version"
fi
done
# If any errors were detected, exit with a non-zero status
if [ "$has_errors" = true ]; then
exit 1
fi
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"sync/atomic"
)
type ArtifactsWrapper struct {
RawMetadata string `json:"rawMetadata"`
}
type Artifacts struct {
Output struct {
Devdoc struct {
StateVariables struct {
Version struct {
Semver string `json:"custom:semver"`
} `json:"version"`
} `json:"stateVariables,omitempty"`
Methods struct {
Version struct {
Semver string `json:"custom:semver"`
} `json:"version()"`
} `json:"methods,omitempty"`
} `json:"devdoc"`
} `json:"output"`
}
var ConstantVersionPattern = regexp.MustCompile(`string.*constant.*version\s+=\s+"([^"]+)";`)
var FunctionVersionPattern = regexp.MustCompile(`^\s+return\s+"((?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)";$`)
var InteropVersionPattern = regexp.MustCompile(`^\s+return\s+string\.concat\(super\.version\(\), "((.*)\+interop(.*)?)"\);`)
func main() {
if err := run(); err != nil {
writeStderr("an error occurred: %v", err)
os.Exit(1)
}
}
func writeStderr(msg string, args ...any) {
_, _ = fmt.Fprintf(os.Stderr, msg+"\n", args...)
}
func run() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
writeStderr("working directory: %s", cwd)
artifactsDir := filepath.Join(cwd, "forge-artifacts")
srcDir := filepath.Join(cwd, "src")
artifactFiles, err := glob(artifactsDir, ".json")
if err != nil {
return fmt.Errorf("failed to get artifact files: %w", err)
}
contractFiles, err := glob(srcDir, ".sol")
if err != nil {
return fmt.Errorf("failed to get contract files: %w", err)
}
var hasErr int32
var outMtx sync.Mutex
fail := func(msg string, args ...any) {
outMtx.Lock()
writeStderr("❌ "+msg, args...)
outMtx.Unlock()
atomic.StoreInt32(&hasErr, 1)
}
sem := make(chan struct{}, runtime.NumCPU())
for contractName, artifactPath := range artifactFiles {
contractName := contractName
artifactPath := artifactPath
sem <- struct{}{}
go func() {
defer func() {
<-sem
}()
af, err := os.Open(artifactPath)
if err != nil {
fail("%s: failed to open contract artifact: %v", contractName, err)
return
}
defer af.Close()
var wrapper ArtifactsWrapper
if err := json.NewDecoder(af).Decode(&wrapper); err != nil {
fail("%s: failed to parse artifact file: %v", contractName, err)
return
}
if wrapper.RawMetadata == "" {
return
}
var artifactData Artifacts
if err := json.Unmarshal([]byte(wrapper.RawMetadata), &artifactData); err != nil {
fail("%s: failed to unwrap artifact metadata: %v", contractName, err)
return
}
artifactVersion := artifactData.Output.Devdoc.StateVariables.Version.Semver
isConstant := true
if artifactData.Output.Devdoc.StateVariables.Version.Semver == "" {
artifactVersion = artifactData.Output.Devdoc.Methods.Version.Semver
isConstant = false
}
if artifactVersion == "" {
return
}
contractPath := contractFiles[contractName]
if contractPath == "" {
fail("%s: Source file not found", contractName)
return
}
cf, err := os.Open(contractPath)
if err != nil {
fail("%s: failed to open contract source: %v", contractName, err)
return
}
defer cf.Close()
sourceData, err := io.ReadAll(cf)
if err != nil {
fail("%s: failed to read contract source: %v", contractName, err)
return
}
var sourceVersion string
if isConstant {
sourceVersion = findLine(sourceData, ConstantVersionPattern)
} else {
sourceVersion = findLine(sourceData, FunctionVersionPattern)
}
// Need to define a special case for interop contracts since they technically
// use an invalid semver format. Checking for sourceVersion == "" allows the
// team to update the format to a valid semver format in the future without
// needing to change this program.
if sourceVersion == "" && strings.HasSuffix(contractName, "Interop") {
sourceVersion = findLine(sourceData, InteropVersionPattern)
}
if sourceVersion == "" {
fail("%s: version not found in source", contractName)
return
}
if sourceVersion != artifactVersion {
fail("%s: version mismatch: source=%s, artifact=%s", contractName, sourceVersion, artifactVersion)
return
}
_, _ = fmt.Fprintf(os.Stderr, "✅ %s: code: %s, artifact: %s\n", contractName, sourceVersion, artifactVersion)
}()
}
for i := 0; i < cap(sem); i++ {
sem <- struct{}{}
}
if atomic.LoadInt32(&hasErr) == 1 {
return fmt.Errorf("semver check failed, see logs above")
}
return nil
}
func glob(dir string, ext string) (map[string]string, error) {
out := make(map[string]string)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() && filepath.Ext(path) == ext {
out[strings.TrimSuffix(filepath.Base(path), ext)] = path
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return out, nil
}
func findLine(in []byte, pattern *regexp.Regexp) string {
scanner := bufio.NewScanner(bytes.NewReader(in))
for scanner.Scan() {
match := pattern.FindStringSubmatch(scanner.Text())
if len(match) > 0 {
return match[1]
}
}
return ""
}
package main
import (
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func TestRegexes(t *testing.T) {
t.Run("ConstantVersionPattern", func(t *testing.T) {
testRegex(t, ConstantVersionPattern, []regexTest{
{
name: "constant version",
input: `string constant version = "1.2.3";`,
capture: "1.2.3",
},
{
name: "constant version with weird spaces",
input: ` string constant version = "1.2.3";`,
capture: "1.2.3",
},
{
name: "constant version with visibility",
input: `string public constant version = "1.2.3";`,
capture: "1.2.3",
},
{
name: "different variable name",
input: `string constant VERSION = "1.2.3";`,
capture: "",
},
{
name: "different type",
input: `uint constant version = 1;`,
capture: "",
},
{
name: "not constant",
input: `string version = "1.2.3";`,
capture: "",
},
{
name: "unterminated",
input: `string constant version = "1.2.3"`,
capture: "",
},
})
})
t.Run("FunctionVersionPattern", func(t *testing.T) {
testRegex(t, FunctionVersionPattern, []regexTest{
{
name: "function version",
input: ` return "1.2.3";`,
capture: "1.2.3",
},
{
name: "function version with weird spaces",
input: ` return "1.2.3";`,
capture: "1.2.3",
},
{
name: "function version with prerelease",
input: ` return "1.2.3-alpha.1";`,
capture: "1.2.3-alpha.1",
},
{
name: "invalid semver",
input: ` return "1.2.cabdab";`,
capture: "",
},
{
name: "not a return statement",
input: `function foo()`,
capture: "",
},
})
})
t.Run("InteropVersionPattern", func(t *testing.T) {
testRegex(t, InteropVersionPattern, []regexTest{
{
name: "interop version",
input: ` return string.concat(super.version(), "+interop");`,
capture: "+interop",
},
{
name: "interop version but as a valid semver",
input: ` return string.concat(super.version(), "0.0.0+interop");`,
capture: "0.0.0+interop",
},
{
name: "not an interop version",
input: ` return string.concat(super.version(), "hello!");`,
capture: "",
},
{
name: "invalid syntax",
input: ` return string.concat(super.version(), "0.0.0+interop`,
capture: "",
},
{
name: "something else is concatted",
input: ` return string.concat("superduper", "mart");`,
capture: "",
},
})
})
}
type regexTest struct {
name string
input string
capture string
}
func testRegex(t *testing.T, re *regexp.Regexp, tests []regexTest) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.Equal(t, test.capture, findLine([]byte(test.input), re))
})
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment