From 858d055bd5994aba1de0a1cb2f56472bfdf87628 Mon Sep 17 00:00:00 2001 From: Melvin Laplanche Date: Sun, 20 Sep 2020 16:59:18 -0700 Subject: [PATCH 1/3] Add tparallel linter --- go.mod | 1 + go.sum | 11 +++++++++++ pkg/golinters/tparallel.go | 21 +++++++++++++++++++++ pkg/lint/lintersdb/manager.go | 10 ++++++++-- test/linters_test.go | 21 +++++++++++++++------ test/testdata/tparallel/tparallel_test.go | 11 +++++++++++ 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 pkg/golinters/tparallel.go create mode 100644 test/testdata/tparallel/tparallel_test.go diff --git a/go.mod b/go.mod index a2e43806b907..33d5a1086f5f 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/mattn/go-colorable v0.1.7 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-ps v1.0.0 + github.com/moricho/tparallel v0.2.0 github.com/nakabonne/nestif v0.3.0 github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 39a6edaabbfc..37c979092349 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,7 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-ole/go-ole v1.2.1 h1:2lOsA72HgjxAuMlKpFiCbHTvu44PIVkZ5hqm3RSdI/E= github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= +github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-toolsmith/astcast v1.0.0 h1:JojxlmI6STnFVG9yOImLeGREv8W2ocNUM+iOhR6jE7g= @@ -162,6 +163,10 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gostaticanalysis/analysisutil v0.0.0-20190318220348-4088753ea4d3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE= github.com/gostaticanalysis/analysisutil v0.0.3 h1:iwp+5/UAyzQSFgQ4uR2sni99sJ8Eo9DEacKWM5pekIg= github.com/gostaticanalysis/analysisutil v0.0.3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE= +github.com/gostaticanalysis/analysisutil v0.1.0 h1:E4c8Y1EQURbBEAHoXc/jBTK7Np14ArT8NPUiSFOl9yc= +github.com/gostaticanalysis/analysisutil v0.1.0/go.mod h1:dMhHRU9KTiDcuLGdy87/2gTR8WruwYZrKdRq9m1O6uw= +github.com/gostaticanalysis/comment v1.3.0 h1:wTVgynbFu8/nz6SGgywA0TcyIoAVsYc7ai/Zp5xNGlw= +github.com/gostaticanalysis/comment v1.3.0/go.mod h1:xMicKDx7XRXYdVwY9f9wQpDJVnqWxw9wCauCMKp+IBI= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= @@ -194,6 +199,7 @@ github.com/jingyugao/rowserrcheck v0.0.0-20191204022205-72ab7603b68a/go.mod h1:x github.com/jirfag/go-printf-func-name v0.0.0-20191110105641-45db9963cdd3 h1:jNYPNLe3d8smommaoQlK7LOA5ESyUJJ+Wf79ZtA7Vp4= github.com/jirfag/go-printf-func-name v0.0.0-20191110105641-45db9963cdd3/go.mod h1:HEWGJkRDzjJY2sqdDwxccsGicWEf9BQOZsq2tV+xzM0= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5 h1:lrdPtrORjGv1HbbEvKWDUAy97mPpFm4B8hp77tcCUJY= github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -252,6 +258,8 @@ github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQz github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/moricho/tparallel v0.2.0 h1:SKKqKl8RnPEl49TRnPL8vxwDVqhUt1R+OqLszWlyoWU= +github.com/moricho/tparallel v0.2.0/go.mod h1:fXEIZxG2vdfl0ZF8b42f5a78EhjjD5mX8qUplsoSU4k= github.com/mozilla/tls-observatory v0.0.0-20200317151703-4fa42e1c2dee/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakabonne/nestif v0.3.0 h1:+yOViDGhg8ygGrmII72nV9B/zGxY188TYpfolntsaPw= @@ -492,6 +500,7 @@ golang.org/x/tools v0.0.0-20190110163146-51295c7ec13a/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190221204921-83362c3779f5/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190307163923-6a08e3108db3/go.mod h1:25r3+/G6/xytQM8iWZKq3Hn0kr0rgFKPUNVEL/dr3z4= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311215038-5c2858a9cfe5/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -518,11 +527,13 @@ golang.org/x/tools v0.0.0-20200414032229-332987a829c3/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200422022333-3d57cf2e726e/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200426102838-f3a5411a4c3b/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200519015757-0d0afa43d58a/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200624225443-88f3c62a19ff/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200625211823-6506e20df31f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200701041122-1837592efa10/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200731060945-b5fad4ed8dd6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200831203904-5a2aa26beb65/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20200918232735-d647fc253266 h1:k7tVuG0g1JwmD3Jh8oAl1vQ1C3jb4Hi/dUl1wWDBJpQ= golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/golinters/tparallel.go b/pkg/golinters/tparallel.go new file mode 100644 index 000000000000..a4b96eb73538 --- /dev/null +++ b/pkg/golinters/tparallel.go @@ -0,0 +1,21 @@ +package golinters + +import ( + "github.com/moricho/tparallel" + "golang.org/x/tools/go/analysis" + + "github.com/golangci/golangci-lint/pkg/golinters/goanalysis" +) + +func NewTparallel() *goanalysis.Linter { + analyzers := []*analysis.Analyzer{ + tparallel.Analyzer, + } + + return goanalysis.NewLinter( + "tparallel", + "tparallel detects inappropriate usage of t.Parallel() method in your Go test codes", + analyzers, + nil, + ).WithLoadMode(goanalysis.LoadModeTypesInfo) +} diff --git a/pkg/lint/lintersdb/manager.go b/pkg/lint/lintersdb/manager.go index 7ffaf9c2e229..502d307db0b8 100644 --- a/pkg/lint/lintersdb/manager.go +++ b/pkg/lint/lintersdb/manager.go @@ -56,8 +56,10 @@ func (m *Manager) WithCustomLinters() *Manager { } func (Manager) AllPresets() []string { - return []string{linter.PresetBugs, linter.PresetComplexity, linter.PresetFormatting, - linter.PresetPerformance, linter.PresetStyle, linter.PresetUnused} + return []string{ + linter.PresetBugs, linter.PresetComplexity, linter.PresetFormatting, + linter.PresetPerformance, linter.PresetStyle, linter.PresetUnused, + } } func (m Manager) allPresetsSet() map[string]bool { @@ -305,6 +307,10 @@ func (m Manager) GetAllSupportedLinterConfigs() []*linter.Config { WithPresets(linter.PresetStyle). WithLoadForGoAnalysis(). WithURL("https://github.com/ssgreg/nlreturn"), + linter.NewConfig(golinters.NewTparallel()). + WithPresets(linter.PresetStyle). + WithLoadForGoAnalysis(). + WithURL("https://github.com/moricho/tparallel"), // nolintlint must be last because it looks at the results of all the previous linters for unused nolint directives linter.NewConfig(golinters.NewNoLintLint()). WithPresets(linter.PresetStyle). diff --git a/test/linters_test.go b/test/linters_test.go index 30734e3beb58..5932a8ebc4b7 100644 --- a/test/linters_test.go +++ b/test/linters_test.go @@ -252,11 +252,20 @@ func TestExtractRunContextFromComments(t *testing.T) { assert.Equal(t, []string{"-Egoimports"}, rc.args) } -func TestGolintConsumesXTestFiles(t *testing.T) { - dir := getTestDataDir("withxtest") - const expIssue = "`if` block ends with a `return` statement, so drop this `else` and outdent its block" +func TestTparallel(t *testing.T) { + sourcePath := filepath.Join(testdataDir, "tparallel", "tparallel_test.go") + args := []string{ + "--disable-all", "--print-issued-lines=false", "--print-linter-name=false", "--out-format=line-number", "--enable", "tparallel", + sourcePath, + } + rc := extractRunContextFromComments(t, sourcePath) + args = append(args, rc.args...) - r := testshared.NewLintRunner(t) - r.Run("--no-config", "--disable-all", "-Egolint", dir).ExpectHasIssue(expIssue) - r.Run("--no-config", "--disable-all", "-Egolint", filepath.Join(dir, "p_test.go")).ExpectHasIssue(expIssue) + cfg, err := yaml.Marshal(rc.config) + assert.NoError(t, err) + + testshared.NewLintRunner(t).RunWithYamlConfig(string(cfg), args...). + ExpectHasIssue( + "testdata/tparallel/tparallel_test.go:7:6: TestSomething should call t.Parallel on the top level as well as its subtests\n", + ) } diff --git a/test/testdata/tparallel/tparallel_test.go b/test/testdata/tparallel/tparallel_test.go new file mode 100644 index 000000000000..95aa57d8b1ed --- /dev/null +++ b/test/testdata/tparallel/tparallel_test.go @@ -0,0 +1,11 @@ +package testdata + +import ( + "testing" +) + +func TestSomething(t *testing.T) { + t.Run("", func(t *testing.T) { + t.Parallel() + }) +} From e39cde01dbfde641140a6e23f71d511ec61feba6 Mon Sep 17 00:00:00 2001 From: Melvin Laplanche Date: Sun, 20 Sep 2020 18:13:47 -0700 Subject: [PATCH 2/3] add tests for current false-positive --- test/linters_test.go | 61 +++++++++++++++---- test/testdata/tparallel/happy_path_test.go | 16 +++++ .../tparallel/missing_subtest_test.go | 12 ++++ ...allel_test.go => missing_toplevel_test.go} | 2 +- 4 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 test/testdata/tparallel/happy_path_test.go create mode 100644 test/testdata/tparallel/missing_subtest_test.go rename test/testdata/tparallel/{tparallel_test.go => missing_toplevel_test.go} (72%) diff --git a/test/linters_test.go b/test/linters_test.go index 5932a8ebc4b7..836777b61720 100644 --- a/test/linters_test.go +++ b/test/linters_test.go @@ -253,19 +253,54 @@ func TestExtractRunContextFromComments(t *testing.T) { } func TestTparallel(t *testing.T) { - sourcePath := filepath.Join(testdataDir, "tparallel", "tparallel_test.go") - args := []string{ - "--disable-all", "--print-issued-lines=false", "--print-linter-name=false", "--out-format=line-number", "--enable", "tparallel", - sourcePath, - } - rc := extractRunContextFromComments(t, sourcePath) - args = append(args, rc.args...) + t.Run("should fail on missing top-level Parallel()", func(t *testing.T) { + sourcePath := filepath.Join(testdataDir, "tparallel", "missing_toplevel_test.go") + args := []string{ + "--disable-all", "--print-issued-lines=false", "--print-linter-name=false", "--out-format=line-number", "--enable", "tparallel", + sourcePath, + } + rc := extractRunContextFromComments(t, sourcePath) + args = append(args, rc.args...) - cfg, err := yaml.Marshal(rc.config) - assert.NoError(t, err) + cfg, err := yaml.Marshal(rc.config) + assert.NoError(t, err) - testshared.NewLintRunner(t).RunWithYamlConfig(string(cfg), args...). - ExpectHasIssue( - "testdata/tparallel/tparallel_test.go:7:6: TestSomething should call t.Parallel on the top level as well as its subtests\n", - ) + testshared.NewLintRunner(t).RunWithYamlConfig(string(cfg), args...). + ExpectHasIssue( + "testdata/tparallel/missing_toplevel_test.go:7:6: TestTopLevel should call t.Parallel on the top level as well as its subtests\n", + ) + }) + + t.Run("should fail on missing subtest Parallel()", func(t *testing.T) { + sourcePath := filepath.Join(testdataDir, "tparallel", "missing_subtest_test.go") + args := []string{ + "--disable-all", "--print-issued-lines=false", "--print-linter-name=false", "--out-format=line-number", "--enable", "tparallel", + sourcePath, + } + rc := extractRunContextFromComments(t, sourcePath) + args = append(args, rc.args...) + + cfg, err := yaml.Marshal(rc.config) + assert.NoError(t, err) + + testshared.NewLintRunner(t).RunWithYamlConfig(string(cfg), args...). + ExpectHasIssue( + "testdata/tparallel/missing_subtest_test.go:7:6: TestSubtests's subtests should call t.Parallel\n", + ) + }) + + t.Run("should pass on parallel test with no subtests", func(t *testing.T) { + sourcePath := filepath.Join(testdataDir, "tparallel", "happy_path_test.go") + args := []string{ + "--disable-all", "--print-issued-lines=false", "--print-linter-name=false", "--out-format=line-number", "--enable", "tparallel", + sourcePath, + } + rc := extractRunContextFromComments(t, sourcePath) + args = append(args, rc.args...) + + cfg, err := yaml.Marshal(rc.config) + assert.NoError(t, err) + + testshared.NewLintRunner(t).RunWithYamlConfig(string(cfg), args...).ExpectNoIssues() + }) } diff --git a/test/testdata/tparallel/happy_path_test.go b/test/testdata/tparallel/happy_path_test.go new file mode 100644 index 000000000000..43073e95943a --- /dev/null +++ b/test/testdata/tparallel/happy_path_test.go @@ -0,0 +1,16 @@ +package testdata + +import ( + "testing" +) + +func TestValidHappyPath(t *testing.T) { + t.Parallel() + t.Run("", func(t *testing.T) { + t.Parallel() + }) +} + +func TestValidNoSubTest(t *testing.T) { + t.Parallel() +} diff --git a/test/testdata/tparallel/missing_subtest_test.go b/test/testdata/tparallel/missing_subtest_test.go new file mode 100644 index 000000000000..02e791ec44fc --- /dev/null +++ b/test/testdata/tparallel/missing_subtest_test.go @@ -0,0 +1,12 @@ +package testdata + +import ( + "testing" +) + +func TestSubtests(t *testing.T) { + t.Parallel() + + t.Run("", func(t *testing.T) { + }) +} diff --git a/test/testdata/tparallel/tparallel_test.go b/test/testdata/tparallel/missing_toplevel_test.go similarity index 72% rename from test/testdata/tparallel/tparallel_test.go rename to test/testdata/tparallel/missing_toplevel_test.go index 95aa57d8b1ed..4b9d6e14e9a7 100644 --- a/test/testdata/tparallel/tparallel_test.go +++ b/test/testdata/tparallel/missing_toplevel_test.go @@ -4,7 +4,7 @@ import ( "testing" ) -func TestSomething(t *testing.T) { +func TestTopLevel(t *testing.T) { t.Run("", func(t *testing.T) { t.Parallel() }) From 71c9c4d68f1b45e3b4ffba3ec998b6bc21443e42 Mon Sep 17 00:00:00 2001 From: Melvin Laplanche Date: Sun, 20 Sep 2020 23:51:14 -0700 Subject: [PATCH 3/3] update linter to 0.2.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 33d5a1086f5f..f6187126de53 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/mattn/go-colorable v0.1.7 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-ps v1.0.0 - github.com/moricho/tparallel v0.2.0 + github.com/moricho/tparallel v0.2.1 github.com/nakabonne/nestif v0.3.0 github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 37c979092349..b08f6eb03d5f 100644 --- a/go.sum +++ b/go.sum @@ -258,8 +258,8 @@ github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQz github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/moricho/tparallel v0.2.0 h1:SKKqKl8RnPEl49TRnPL8vxwDVqhUt1R+OqLszWlyoWU= -github.com/moricho/tparallel v0.2.0/go.mod h1:fXEIZxG2vdfl0ZF8b42f5a78EhjjD5mX8qUplsoSU4k= +github.com/moricho/tparallel v0.2.1 h1:95FytivzT6rYzdJLdtfn6m1bfFJylOJK41+lgv/EHf4= +github.com/moricho/tparallel v0.2.1/go.mod h1:fXEIZxG2vdfl0ZF8b42f5a78EhjjD5mX8qUplsoSU4k= github.com/mozilla/tls-observatory v0.0.0-20200317151703-4fa42e1c2dee/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakabonne/nestif v0.3.0 h1:+yOViDGhg8ygGrmII72nV9B/zGxY188TYpfolntsaPw=