diff --git a/pkg/analysis/passes/analysis.go b/pkg/analysis/passes/analysis.go index 874354cb..117843b9 100644 --- a/pkg/analysis/passes/analysis.go +++ b/pkg/analysis/passes/analysis.go @@ -38,6 +38,7 @@ import ( "github.com/grafana/plugin-validator/pkg/analysis/passes/pluginname" "github.com/grafana/plugin-validator/pkg/analysis/passes/provenance" "github.com/grafana/plugin-validator/pkg/analysis/passes/published" + "github.com/grafana/plugin-validator/pkg/analysis/passes/reactcompat" "github.com/grafana/plugin-validator/pkg/analysis/passes/readme" "github.com/grafana/plugin-validator/pkg/analysis/passes/restrictivedep" "github.com/grafana/plugin-validator/pkg/analysis/passes/safelinks" @@ -90,6 +91,7 @@ var Analyzers = []*analysis.Analyzer{ pluginname.Analyzer, provenance.Analyzer, published.Analyzer, + reactcompat.Analyzer, readme.Analyzer, restrictivedep.Analyzer, screenshots.Analyzer, diff --git a/pkg/analysis/passes/reactcompat/reactcompat.go b/pkg/analysis/passes/reactcompat/reactcompat.go new file mode 100644 index 00000000..73ae2b75 --- /dev/null +++ b/pkg/analysis/passes/reactcompat/reactcompat.go @@ -0,0 +1,179 @@ +package reactcompat + +import ( + "bytes" + "fmt" + "regexp" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/analysis/passes/modulejs" +) + +const react19UpgradeGuide = "https://react.dev/blog/2024/04/25/react-19-upgrade-guide" + +var ( + react19PropTypes = &analysis.Rule{Name: "react-19-prop-types", Severity: analysis.Warning} + react19LegacyContext = &analysis.Rule{Name: "react-19-legacy-context", Severity: analysis.Warning} + react19StringRefs = &analysis.Rule{Name: "react-19-string-refs", Severity: analysis.Warning} + react19CreateFactory = &analysis.Rule{Name: "react-19-create-factory", Severity: analysis.Warning} + react19FindDOMNode = &analysis.Rule{Name: "react-19-find-dom-node", Severity: analysis.Warning} + react19LegacyRender = &analysis.Rule{Name: "react-19-legacy-render", Severity: analysis.Warning} + react19SecretInternals = &analysis.Rule{Name: "react-19-secret-internals", Severity: analysis.Warning} + react19Compatible = &analysis.Rule{Name: "react-19-compatible", Severity: analysis.OK} +) + +var Analyzer = &analysis.Analyzer{ + Name: "reactcompat", + Requires: []*analysis.Analyzer{modulejs.Analyzer}, + Run: run, + Rules: []*analysis.Rule{ + react19PropTypes, + react19LegacyContext, + react19StringRefs, + react19CreateFactory, + react19FindDOMNode, + react19LegacyRender, + react19SecretInternals, + react19Compatible, + }, + ReadmeInfo: analysis.ReadmeInfo{ + Name: "React 19 Compatibility", + Description: "Detects usage of React APIs removed or deprecated in React 19.", + }, +} + +// detector checks a single module.js file for a specific pattern. +type detector interface { + Detect(moduleJs []byte) bool + Pattern() string +} + +type containsBytesDetector struct { + pattern []byte +} + +func (d *containsBytesDetector) Detect(moduleJs []byte) bool { + return bytes.Contains(moduleJs, d.pattern) +} + +func (d *containsBytesDetector) Pattern() string { + return string(d.pattern) +} + +type regexDetector struct { + regex *regexp.Regexp +} + +func (d *regexDetector) Detect(moduleJs []byte) bool { + return d.regex.Match(moduleJs) +} + +func (d *regexDetector) Pattern() string { + return d.regex.String() +} + +// reactPattern groups a rule, a human-readable description, and the detectors that trigger it. +type reactPattern struct { + rule *analysis.Rule + title string + description string + detectors []detector +} + +var reactPatterns = []reactPattern{ + { + rule: react19PropTypes, + title: "module.js: Uses removed React API propTypes or defaultProps", + description: "Detected usage of '%s'. propTypes and defaultProps on function components were removed in React 19.", + detectors: []detector{ + &containsBytesDetector{pattern: []byte(".propTypes=")}, + &containsBytesDetector{pattern: []byte(".defaultProps=")}, + }, + }, + { + rule: react19LegacyContext, + title: "module.js: Uses removed React legacy context API", + description: "Detected usage of '%s'. contextTypes, childContextTypes, and getChildContext were removed in React 19.", + detectors: []detector{ + &containsBytesDetector{pattern: []byte(".contextTypes=")}, + &containsBytesDetector{pattern: []byte(".childContextTypes=")}, + &containsBytesDetector{pattern: []byte("getChildContext")}, + }, + }, + { + rule: react19StringRefs, + title: "module.js: Uses removed React string refs", + description: "Detected usage of '%s'. String refs were removed in React 19. Use callback refs or React.createRef() instead.", + detectors: []detector{ + ®exDetector{regex: regexp.MustCompile(`ref:"[^"]+?"`)}, + ®exDetector{regex: regexp.MustCompile(`ref:'[^']+'`)}, + }, + }, + { + rule: react19CreateFactory, + title: "module.js: Uses removed React.createFactory", + description: "Detected usage of '%s'. React.createFactory was removed in React 19. Use JSX instead.", + detectors: []detector{ + &containsBytesDetector{pattern: []byte("createFactory(")}, + }, + }, + { + rule: react19FindDOMNode, + title: "module.js: Uses removed ReactDOM.findDOMNode", + description: "Detected usage of '%s'. ReactDOM.findDOMNode was removed in React 19. Use DOM refs instead.", + detectors: []detector{ + &containsBytesDetector{pattern: []byte("findDOMNode(")}, + }, + }, + { + rule: react19LegacyRender, + title: "module.js: Uses removed ReactDOM.render or unmountComponentAtNode", + description: "Detected usage of '%s'. ReactDOM.render and unmountComponentAtNode were removed in React 19. Use createRoot instead.", + detectors: []detector{ + &containsBytesDetector{pattern: []byte("ReactDOM.render(")}, + &containsBytesDetector{pattern: []byte("unmountComponentAtNode(")}, + }, + }, + { + rule: react19SecretInternals, + title: "module.js: Uses React internal __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED", + description: "Detected usage of '%s'. This internal was removed in React 19.", + detectors: []detector{ + &containsBytesDetector{pattern: []byte("__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED")}, + }, + }, +} + +func run(pass *analysis.Pass) (interface{}, error) { + moduleJsMap, ok := pass.ResultOf[modulejs.Analyzer].(map[string][]byte) + if !ok || len(moduleJsMap) == 0 { + return nil, nil + } + + for _, pattern := range reactPatterns { + matched := false + matchedPattern := "" + + outer: + for _, content := range moduleJsMap { + for _, d := range pattern.detectors { + if d.Detect(content) { + matched = true + matchedPattern = d.Pattern() + break outer + } + } + } + + if matched { + pass.ReportResult( + pass.AnalyzerName, + pattern.rule, + pattern.title, + fmt.Sprintf(pattern.description+" See: "+react19UpgradeGuide, matchedPattern), + ) + } + } + + return nil, nil +} diff --git a/pkg/analysis/passes/reactcompat/reactcompat_test.go b/pkg/analysis/passes/reactcompat/reactcompat_test.go new file mode 100644 index 00000000..9c2e4800 --- /dev/null +++ b/pkg/analysis/passes/reactcompat/reactcompat_test.go @@ -0,0 +1,267 @@ +package reactcompat + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/analysis/passes/modulejs" + "github.com/grafana/plugin-validator/pkg/testpassinterceptor" +) + +func newPass(interceptor *testpassinterceptor.TestPassInterceptor, content map[string][]byte) *analysis.Pass { + return &analysis.Pass{ + RootDir: filepath.Join("./"), + ResultOf: map[*analysis.Analyzer]interface{}{ + modulejs.Analyzer: content, + }, + Report: interceptor.ReportInterceptor(), + } +} + +func TestCleanPlugin(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`import { PanelPlugin } from '@grafana/data'`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + // No warnings; the OK rule only fires when ReportAll is set. + require.Len(t, interceptor.Diagnostics, 0) +} + +func TestNoModuleJs(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := &analysis.Pass{ + RootDir: filepath.Join("./"), + ResultOf: map[*analysis.Analyzer]interface{}{ + modulejs.Analyzer: map[string][]byte{}, + }, + Report: interceptor.ReportInterceptor(), + } + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 0) +} + +func TestPropTypes(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`MyComponent.propTypes={name:PropTypes.string}`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-prop-types", interceptor.Diagnostics[0].Name) + require.Equal(t, analysis.Warning, interceptor.Diagnostics[0].Severity) +} + +func TestDefaultProps(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`MyComponent.defaultProps={name:"default"}`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-prop-types", interceptor.Diagnostics[0].Name) +} + +func TestContextTypes(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`MyComponent.contextTypes={theme:PropTypes.object}`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-legacy-context", interceptor.Diagnostics[0].Name) +} + +func TestChildContextTypes(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`MyComponent.childContextTypes={theme:PropTypes.object}`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-legacy-context", interceptor.Diagnostics[0].Name) +} + +func TestGetChildContext(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`getChildContext(){return{theme:this.state.theme}}`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-legacy-context", interceptor.Diagnostics[0].Name) +} + +func TestStringRefsDoubleQuote(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(``), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-string-refs", interceptor.Diagnostics[0].Name) +} + +func TestStringRefsSingleQuote(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(``), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-string-refs", interceptor.Diagnostics[0].Name) +} + +func TestStringRefsNearMiss(t *testing.T) { + // ref: without quotes around the value should not trigger + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`ref:someVariable`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 0) +} + +func TestCreateFactory(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`var el=React.createFactory(MyComponent)`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-create-factory", interceptor.Diagnostics[0].Name) +} + +func TestFindDOMNode(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`var node=ReactDOM.findDOMNode(this)`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-find-dom-node", interceptor.Diagnostics[0].Name) +} + +func TestReactDOMRender(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`ReactDOM.render(,document.getElementById("root"))`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-legacy-render", interceptor.Diagnostics[0].Name) +} + +func TestUnmountComponentAtNode(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`ReactDOM.unmountComponentAtNode(container)`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-legacy-render", interceptor.Diagnostics[0].Name) +} + +func TestLegacyRenderNearMiss(t *testing.T) { + // .render( without the ReactDOM. prefix should not trigger the legacy-render rule + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`component.render(props)`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 0) +} + +func TestSecretInternals(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-secret-internals", interceptor.Diagnostics[0].Name) +} + +func TestMultipleIssues(t *testing.T) { + // A bundle that hits several distinct rules should produce one diagnostic per rule. + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte( + `MyComponent.propTypes={name:PropTypes.string}` + + `ReactDOM.render(,document.getElementById("root"))` + + `var node=ReactDOM.findDOMNode(this)`, + ), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 3) + + names := make([]string, 0, 3) + for _, d := range interceptor.Diagnostics { + names = append(names, d.Name) + } + require.Contains(t, names, "react-19-prop-types") + require.Contains(t, names, "react-19-legacy-render") + require.Contains(t, names, "react-19-find-dom-node") +} + +func TestDetailContainsUpgradeGuideLink(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`MyComponent.propTypes={}`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Contains(t, interceptor.Diagnostics[0].Detail, react19UpgradeGuide) +} + +func TestEachRuleReportedOnceEvenWithMultipleMatches(t *testing.T) { + // Both .propTypes= and .defaultProps= match the same rule; only one diagnostic should be emitted. + var interceptor testpassinterceptor.TestPassInterceptor + pass := newPass(&interceptor, map[string][]byte{ + "module.js": []byte(`MyComponent.propTypes={} MyComponent.defaultProps={}`), + }) + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal(t, "react-19-prop-types", interceptor.Diagnostics[0].Name) +}