From 00da58c657d0e4eafe0b12d6c97c72711d8bbddf Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 30 Mar 2026 09:18:41 +0200 Subject: [PATCH 1/5] feat: add React 19 compatibility checker for plugins Scan plugin module.js bundles for patterns that indicate incompatibility with React 19: removed PropTypes/defaultProps, legacy context, string refs, ReactDOM.render, ReactDOM.findDOMNode, legacy lifecycle methods, and removed React.createFactory. --- pkg/analysis/passes/analysis.go | 2 + .../passes/reactcompat/reactcompat.go | 179 ++++++++++++ .../passes/reactcompat/reactcompat_test.go | 267 ++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 pkg/analysis/passes/reactcompat/reactcompat.go create mode 100644 pkg/analysis/passes/reactcompat/reactcompat_test.go 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) +} From c4a1f869b88097c34c1efec4b765b4cd459312bd Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Wed, 1 Apr 2026 17:57:11 +0200 Subject: [PATCH 2/5] refactor: rewrite reactcompat analyzer to use @grafana/react-detect Replace the in-process regex-based React 19 compatibility checker with a shell-out to npx @grafana/react-detect. This delegates detection logic to the upstream package so rules are maintained in a single place. Key changes: - Dependency changed from modulejs.Analyzer to archive.Analyzer - Runs npx -y @grafana/react-detect@latest --json against the archive - Creates temp dir with dist/ symlink (what react-detect expects) - Dynamic rules from tool output, respecting react19Issue.Disabled config - Graceful skip when npx is not in PATH - 60s timeout, stderr capture for debug logging --- .../passes/reactcompat/reactcompat.go | 304 ++++++++------ .../passes/reactcompat/reactcompat_test.go | 393 +++++++++--------- 2 files changed, 372 insertions(+), 325 deletions(-) diff --git a/pkg/analysis/passes/reactcompat/reactcompat.go b/pkg/analysis/passes/reactcompat/reactcompat.go index 73ae2b75..18745073 100644 --- a/pkg/analysis/passes/reactcompat/reactcompat.go +++ b/pkg/analysis/passes/reactcompat/reactcompat.go @@ -2,178 +2,220 @@ package reactcompat import ( "bytes" + "context" + "encoding/json" "fmt" - "regexp" + "os" + "os/exec" + "path/filepath" + "strings" + "time" "github.com/grafana/plugin-validator/pkg/analysis" - "github.com/grafana/plugin-validator/pkg/analysis/passes/modulejs" + "github.com/grafana/plugin-validator/pkg/analysis/passes/archive" + "github.com/grafana/plugin-validator/pkg/logme" ) -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} + react19Issue = &analysis.Rule{ + Name: "react-19-issue", + Severity: analysis.Warning, + } + react19Compatible = &analysis.Rule{ + Name: "react-19-compatible", + Severity: analysis.OK, + } ) +// Analyzer checks for React 19 compatibility issues in the plugin bundle by +// delegating to npx @grafana/react-detect. It silently skips if npx is not +// available in PATH. var Analyzer = &analysis.Analyzer{ Name: "reactcompat", - Requires: []*analysis.Analyzer{modulejs.Analyzer}, + Requires: []*analysis.Analyzer{archive.Analyzer}, Run: run, - Rules: []*analysis.Rule{ - react19PropTypes, - react19LegacyContext, - react19StringRefs, - react19CreateFactory, - react19FindDOMNode, - react19LegacyRender, - react19SecretInternals, - react19Compatible, - }, + Rules: []*analysis.Rule{react19Issue, react19Compatible}, ReadmeInfo: analysis.ReadmeInfo{ Name: "React 19 Compatibility", - Description: "Detects usage of React APIs removed or deprecated in React 19.", + Description: "Detects usage of React APIs removed or deprecated in React 19 using @grafana/react-detect.", }, } -// detector checks a single module.js file for a specific pattern. -type detector interface { - Detect(moduleJs []byte) bool - Pattern() string +// reactDetectOutput is the top-level JSON structure emitted by @grafana/react-detect. +type reactDetectOutput struct { + SourceCodeIssues map[string][]sourceCodeIssue `json:"sourceCodeIssues"` + DependencyIssues []dependencyIssue `json:"dependencyIssues"` } -type containsBytesDetector struct { - pattern []byte +type sourceCodeIssue struct { + Pattern string `json:"pattern"` + Severity string `json:"severity"` + Location location `json:"location"` + Problem string `json:"problem"` + Fix fix `json:"fix"` + Link string `json:"link"` } -func (d *containsBytesDetector) Detect(moduleJs []byte) bool { - return bytes.Contains(moduleJs, d.pattern) +type location struct { + File string `json:"file"` + Line int `json:"line"` + Column int `json:"column"` } -func (d *containsBytesDetector) Pattern() string { - return string(d.pattern) +type fix struct { + Description string `json:"description"` } -type regexDetector struct { - regex *regexp.Regexp +type dependencyIssue struct { + Pattern string `json:"pattern"` + Severity string `json:"severity"` + Problem string `json:"problem"` + Link string `json:"link"` + PackageNames []string `json:"packageNames"` } -func (d *regexDetector) Detect(moduleJs []byte) bool { - return d.regex.Match(moduleJs) +func run(pass *analysis.Pass) (any, error) { + archiveDir, ok := pass.ResultOf[archive.Analyzer].(string) + if !ok || archiveDir == "" { + return nil, nil + } + + npxPath, err := exec.LookPath("npx") + if err != nil { + logme.DebugFln("npx not found in PATH, skipping react-detect") + return nil, nil + } + logme.DebugFln("npx path: %s", npxPath) + + tmpDir, cleanup, err := prepareTmpDir(archiveDir) + if err != nil { + logme.DebugFln("failed to prepare temp dir for react-detect: %v", err) + return nil, nil + } + defer cleanup() + + output, err := runReactDetect(npxPath, tmpDir) + if err != nil { + logme.DebugFln("react-detect failed: %v", err) + return nil, nil + } + + issueCount := reportIssues(pass, output) + + if issueCount == 0 && react19Compatible.ReportAll { + pass.ReportResult( + pass.AnalyzerName, + react19Compatible, + "Plugin is compatible with React 19", + "No React 19 compatibility issues were detected.", + ) + } + + return nil, nil } -func (d *regexDetector) Pattern() string { - return d.regex.String() +// prepareTmpDir creates a temporary directory with a dist/ symlink pointing at +// archiveDir. react-detect expects the plugin files to live under dist/. +// The returned cleanup function removes the temp directory. +func prepareTmpDir(archiveDir string) (string, func(), error) { + tmpDir, err := os.MkdirTemp("", "reactcompat-*") + if err != nil { + return "", nil, fmt.Errorf("create temp dir: %w", err) + } + + cleanup := func() { os.RemoveAll(tmpDir) } + + distLink := filepath.Join(tmpDir, "dist") + if err := os.Symlink(archiveDir, distLink); err != nil { + cleanup() + return "", nil, fmt.Errorf("create dist symlink: %w", err) + } + + return tmpDir, cleanup, nil } -// 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 +// runReactDetect shells out to react-detect and returns the parsed output. +func runReactDetect(npxPath, pluginRoot string) (*reactDetectOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // --json: machine-readable output. --skipBuildTooling: avoid running bundlers. + // --noErrorExitCode: always exit 0 so we can parse partial output on warnings. + // Dependency issues are intentionally included (no --skipDependencies). + args := []string{ + "-y", + "@grafana/react-detect@latest", + "--json", + "--pluginRoot", pluginRoot, + "--skipBuildTooling", + "--noErrorExitCode", + } + logme.DebugFln("running react-detect with args: %v", args) + + cmd := exec.CommandContext(ctx, npxPath, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("react-detect exited with error: %w (stderr: %s)", err, stderr.String()) + } + + return parseResults(out) } -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")}, - }, - }, +// parseResults unmarshals the raw JSON bytes from react-detect. +func parseResults(data []byte) (*reactDetectOutput, error) { + var output reactDetectOutput + if err := json.Unmarshal(data, &output); err != nil { + return nil, fmt.Errorf("parse react-detect output: %w", err) + } + return &output, nil } -func run(pass *analysis.Pass) (interface{}, error) { - moduleJsMap, ok := pass.ResultOf[modulejs.Analyzer].(map[string][]byte) - if !ok || len(moduleJsMap) == 0 { - return nil, nil +// reportIssues translates the react-detect output into pass diagnostics and +// returns the total number of issues reported. +func reportIssues(pass *analysis.Pass, output *reactDetectOutput) int { + if react19Issue.Disabled { + return 0 + } + + if output == nil { + return 0 } - for _, pattern := range reactPatterns { - matched := false - matchedPattern := "" + count := 0 - outer: - for _, content := range moduleJsMap { - for _, d := range pattern.detectors { - if d.Detect(content) { - matched = true - matchedPattern = d.Pattern() - break outer - } + for _, issues := range output.SourceCodeIssues { + for _, issue := range issues { + rule := &analysis.Rule{ + Name: fmt.Sprintf("react-19-%s", issue.Pattern), + Severity: analysis.Warning, } + detail := fmt.Sprintf( + "Detected in %s at line %d. %s See: %s", + issue.Location.File, + issue.Location.Line, + issue.Fix.Description, + issue.Link, + ) + pass.ReportResult(pass.AnalyzerName, rule, issue.Problem, detail) + count++ } + } - if matched { - pass.ReportResult( - pass.AnalyzerName, - pattern.rule, - pattern.title, - fmt.Sprintf(pattern.description+" See: "+react19UpgradeGuide, matchedPattern), - ) + for _, issue := range output.DependencyIssues { + rule := &analysis.Rule{ + Name: fmt.Sprintf("react-19-dep-%s", issue.Pattern), + Severity: analysis.Warning, } + detail := fmt.Sprintf( + "Affected packages: %s. See: %s", + strings.Join(issue.PackageNames, ", "), + issue.Link, + ) + pass.ReportResult(pass.AnalyzerName, rule, issue.Problem, detail) + count++ } - return nil, nil + return count } diff --git a/pkg/analysis/passes/reactcompat/reactcompat_test.go b/pkg/analysis/passes/reactcompat/reactcompat_test.go index 9c2e4800..a473d894 100644 --- a/pkg/analysis/passes/reactcompat/reactcompat_test.go +++ b/pkg/analysis/passes/reactcompat/reactcompat_test.go @@ -1,267 +1,272 @@ package reactcompat import ( + "os" + "os/exec" "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/analysis/passes/archive" "github.com/grafana/plugin-validator/pkg/testpassinterceptor" ) -func newPass(interceptor *testpassinterceptor.TestPassInterceptor, content map[string][]byte) *analysis.Pass { +func newPass(interceptor *testpassinterceptor.TestPassInterceptor, archiveDir string) *analysis.Pass { return &analysis.Pass{ - RootDir: filepath.Join("./"), + AnalyzerName: "reactcompat", + RootDir: filepath.Join("./"), ResultOf: map[*analysis.Analyzer]interface{}{ - modulejs.Analyzer: content, + archive.Analyzer: archiveDir, }, 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{}, +// TestParseResults verifies that a valid JSON payload is correctly decoded and +// mapped to the expected diagnostics. +func TestParseResults(t *testing.T) { + jsonPayload := []byte(`{ + "sourceCodeIssues": { + "usePropTypes": [ + { + "pattern": "usePropTypes", + "severity": "critical", + "location": {"type": "source-map", "file": "module.js", "line": 42, "column": 10}, + "problem": "Uses deprecated propTypes", + "fix": {"description": "Remove propTypes usage."}, + "link": "https://react.dev/blog/2024/04/25/react-19-upgrade-guide" + } + ] }, - Report: interceptor.ReportInterceptor(), - } - - _, err := Analyzer.Run(pass) + "dependencyIssues": [ + { + "pattern": "oldReactDom", + "severity": "critical", + "problem": "Depends on old react-dom", + "link": "https://example.com", + "packageNames": ["react-dom", "react"] + } + ] + }`) + + output, err := parseResults(jsonPayload) require.NoError(t, err) - require.Len(t, interceptor.Diagnostics, 0) + require.Len(t, output.SourceCodeIssues, 1) + require.Len(t, output.SourceCodeIssues["usePropTypes"], 1) + require.Len(t, output.DependencyIssues, 1) + + sc := output.SourceCodeIssues["usePropTypes"][0] + require.Equal(t, "usePropTypes", sc.Pattern) + require.Equal(t, "module.js", sc.Location.File) + require.Equal(t, 42, sc.Location.Line) + require.Equal(t, "Uses deprecated propTypes", sc.Problem) + require.Equal(t, "Remove propTypes usage.", sc.Fix.Description) + require.Equal(t, "https://react.dev/blog/2024/04/25/react-19-upgrade-guide", sc.Link) + + dep := output.DependencyIssues[0] + require.Equal(t, "oldReactDom", dep.Pattern) + require.Equal(t, []string{"react-dom", "react"}, dep.PackageNames) } -func TestPropTypes(t *testing.T) { - var interceptor testpassinterceptor.TestPassInterceptor - pass := newPass(&interceptor, map[string][]byte{ - "module.js": []byte(`MyComponent.propTypes={name:PropTypes.string}`), - }) +// TestParseResultsEmpty verifies that a payload with no issues produces an +// empty but non-nil result. +func TestParseResultsEmpty(t *testing.T) { + jsonPayload := []byte(`{"sourceCodeIssues": {}, "dependencyIssues": []}`) - _, err := Analyzer.Run(pass) + output, err := parseResults(jsonPayload) 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) + require.NotNil(t, output) + require.Len(t, output.SourceCodeIssues, 0) + require.Len(t, output.DependencyIssues, 0) } -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) +// TestParseResultsMalformed verifies that garbage input returns an error rather +// than a panic or silent zero value. +func TestParseResultsMalformed(t *testing.T) { + _, err := parseResults([]byte(`not valid json {{{`)) + require.Error(t, err) } -func TestContextTypes(t *testing.T) { +// TestReportIssuesSourceCode verifies correct diagnostic generation for source +// code issues. +func TestReportIssuesSourceCode(t *testing.T) { var interceptor testpassinterceptor.TestPassInterceptor - pass := newPass(&interceptor, map[string][]byte{ - "module.js": []byte(`MyComponent.contextTypes={theme:PropTypes.object}`), - }) + pass := newPass(&interceptor, "/some/archive/dir") + + output := &reactDetectOutput{ + SourceCodeIssues: map[string][]sourceCodeIssue{ + "usePropTypes": { + { + Pattern: "usePropTypes", + Severity: "critical", + Location: location{File: "module.js", Line: 10, Column: 5}, + Problem: "Uses deprecated propTypes", + Fix: fix{Description: "Remove propTypes."}, + Link: "https://react.dev/upgrade", + }, + }, + }, + } - _, err := Analyzer.Run(pass) - require.NoError(t, err) + count := reportIssues(pass, output) + require.Equal(t, 1, count) 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) + d := interceptor.Diagnostics[0] + require.Equal(t, "react-19-usePropTypes", d.Name) + require.Equal(t, analysis.Warning, d.Severity) + require.Equal(t, "Uses deprecated propTypes", d.Title) + require.Contains(t, d.Detail, "module.js") + require.Contains(t, d.Detail, "10") + require.Contains(t, d.Detail, "Remove propTypes.") + require.Contains(t, d.Detail, "https://react.dev/upgrade") } -func TestGetChildContext(t *testing.T) { +// TestReportIssuesDependency verifies correct diagnostic generation for +// dependency issues. +func TestReportIssuesDependency(t *testing.T) { var interceptor testpassinterceptor.TestPassInterceptor - pass := newPass(&interceptor, map[string][]byte{ - "module.js": []byte(`getChildContext(){return{theme:this.state.theme}}`), - }) + pass := newPass(&interceptor, "/some/archive/dir") + + output := &reactDetectOutput{ + DependencyIssues: []dependencyIssue{ + { + Pattern: "oldReactDom", + Severity: "critical", + Problem: "Depends on old react-dom", + Link: "https://example.com/fix", + PackageNames: []string{"react-dom", "react"}, + }, + }, + } - _, err := Analyzer.Run(pass) - require.NoError(t, err) + count := reportIssues(pass, output) + require.Equal(t, 1, count) 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) + d := interceptor.Diagnostics[0] + require.Equal(t, "react-19-dep-oldReactDom", d.Name) + require.Equal(t, analysis.Warning, d.Severity) + require.Equal(t, "Depends on old react-dom", d.Title) + require.Contains(t, d.Detail, "react-dom, react") + require.Contains(t, d.Detail, "https://example.com/fix") } -func TestStringRefsSingleQuote(t *testing.T) { +// TestReportIssuesNil verifies that a nil output produces no diagnostics. +func TestReportIssuesNil(t *testing.T) { var interceptor testpassinterceptor.TestPassInterceptor - pass := newPass(&interceptor, map[string][]byte{ - "module.js": []byte(``), - }) + pass := newPass(&interceptor, "/some/archive/dir") - _, 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) + count := reportIssues(pass, nil) + require.Equal(t, 0, count) 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)`), - }) +// TestPrepareTmpDir verifies that prepareTmpDir creates the expected symlink +// and that the cleanup function removes the directory. +func TestPrepareTmpDir(t *testing.T) { + archiveDir := t.TempDir() - _, err := Analyzer.Run(pass) + tmpDir, cleanup, err := prepareTmpDir(archiveDir) require.NoError(t, err) - require.Len(t, interceptor.Diagnostics, 1) - require.Equal(t, "react-19-create-factory", interceptor.Diagnostics[0].Name) -} + require.NotEmpty(t, tmpDir) -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) + distLink := filepath.Join(tmpDir, "dist") + target, err := os.Readlink(distLink) require.NoError(t, err) - require.Len(t, interceptor.Diagnostics, 1) - require.Equal(t, "react-19-find-dom-node", interceptor.Diagnostics[0].Name) -} + require.Equal(t, archiveDir, target) -func TestReactDOMRender(t *testing.T) { - var interceptor testpassinterceptor.TestPassInterceptor - pass := newPass(&interceptor, map[string][]byte{ - "module.js": []byte(`ReactDOM.render(,document.getElementById("root"))`), - }) + cleanup() - _, 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) + _, statErr := os.Stat(tmpDir) + require.True(t, os.IsNotExist(statErr), "temp dir should be removed after cleanup") } -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) -} +// TestNpxNotAvailable verifies that the analyzer silently skips (nil, nil) when +// npx is not found in PATH, producing no diagnostics. +func TestNpxNotAvailable(t *testing.T) { + if _, err := exec.LookPath("npx"); err == nil { + t.Skip("npx is available in this environment; skipping npx-not-found test") + } -func TestLegacyRenderNearMiss(t *testing.T) { - // .render( without the ReactDOM. prefix should not trigger the legacy-render rule + archiveDir := t.TempDir() var interceptor testpassinterceptor.TestPassInterceptor - pass := newPass(&interceptor, map[string][]byte{ - "module.js": []byte(`component.render(props)`), - }) + pass := newPass(&interceptor, archiveDir) - _, err := Analyzer.Run(pass) + result, err := Analyzer.Run(pass) require.NoError(t, err) + require.Nil(t, result) require.Len(t, interceptor.Diagnostics, 0) } -func TestSecretInternals(t *testing.T) { +// TestReportIssuesCombined verifies that multiple source code issue groups and a +// dependency issue are all counted and reported correctly in a single call. +func TestReportIssuesCombined(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) -} + pass := newPass(&interceptor, "/some/archive/dir") + + output := &reactDetectOutput{ + SourceCodeIssues: map[string][]sourceCodeIssue{ + "usePropTypes": { + { + Pattern: "usePropTypes", + Severity: "critical", + Location: location{File: "module.js", Line: 10, Column: 5}, + Problem: "Uses deprecated propTypes", + Fix: fix{Description: "Remove propTypes."}, + Link: "https://react.dev/upgrade", + }, + }, + "findDOMNode": { + { + Pattern: "findDOMNode", + Severity: "critical", + Location: location{File: "other.js", Line: 20, Column: 3}, + Problem: "Uses removed findDOMNode", + Fix: fix{Description: "Use a ref instead."}, + Link: "https://react.dev/upgrade#finddomnode", + }, + }, + }, + DependencyIssues: []dependencyIssue{ + { + Pattern: "oldReactDom", + Severity: "critical", + Problem: "Depends on old react-dom", + Link: "https://example.com/fix", + PackageNames: []string{"react-dom"}, + }, + }, + } -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) + count := reportIssues(pass, output) + require.Equal(t, 3, count) require.Len(t, interceptor.Diagnostics, 3) - names := make([]string, 0, 3) + ruleNames := make([]string, 0, 3) for _, d := range interceptor.Diagnostics { - names = append(names, d.Name) + ruleNames = append(ruleNames, 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) + require.Contains(t, ruleNames, "react-19-usePropTypes") + require.Contains(t, ruleNames, "react-19-findDOMNode") + require.Contains(t, ruleNames, "react-19-dep-oldReactDom") } -func TestEachRuleReportedOnceEvenWithMultipleMatches(t *testing.T) { - // Both .propTypes= and .defaultProps= match the same rule; only one diagnostic should be emitted. +// TestNoArchiveDir verifies that a missing archive result produces no diagnostics. +func TestNoArchiveDir(t *testing.T) { var interceptor testpassinterceptor.TestPassInterceptor - pass := newPass(&interceptor, map[string][]byte{ - "module.js": []byte(`MyComponent.propTypes={} MyComponent.defaultProps={}`), - }) + pass := &analysis.Pass{ + RootDir: filepath.Join("./"), + ResultOf: map[*analysis.Analyzer]interface{}{ + archive.Analyzer: nil, + }, + Report: interceptor.ReportInterceptor(), + } - _, err := Analyzer.Run(pass) + result, 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.Nil(t, result) + require.Len(t, interceptor.Diagnostics, 0) } From f830bc440a0dfed666e1028c24d6bc5977bff0b8 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 2 Apr 2026 09:39:26 +0200 Subject: [PATCH 3/5] fix: add missing provenance expectation in integration test The yesoreyeram-infinity-datasource test case was missing the expected provenance recommendation diagnostic, causing the integration test to fail. This was a pre-existing issue on main, not introduced by this PR. --- pkg/cmd/plugincheck2/main_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/cmd/plugincheck2/main_test.go b/pkg/cmd/plugincheck2/main_test.go index 8cd71385..916186da 100644 --- a/pkg/cmd/plugincheck2/main_test.go +++ b/pkg/cmd/plugincheck2/main_test.go @@ -159,6 +159,14 @@ func TestIntegration(t *testing.T) { Name: "sponsorshiplink", }, }, + "provenance": { + { + Severity: "recommendation", + Title: "No provenance attestation. This plugin was built without build verification", + Detail: "Cannot verify plugin build. It is recommended to use a pipeline that supports provenance attestation, such as GitHub Actions. https://github.com/grafana/plugin-actions/tree/main/build-plugin", + Name: "no-provenance-attestation", + }, + }, }, }, }, From 71a1f71a3029fcd6d9cdaaf754476c490201957b Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 2 Apr 2026 10:12:52 +0200 Subject: [PATCH 4/5] fix: add reactcompat to README analyzers table The genreadme test requires the README.md analyzers table to be in sync with registered analyzers. Also revert the incorrect provenance test expectation - the provenance check does not run in Docker CI. --- README.md | 1 + pkg/cmd/plugincheck2/main_test.go | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index daed6ded..3d94686f 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,7 @@ Run "mage gen:readme" to regenerate this section. | Plugin Name formatting / `pluginname` | Validates the plugin ID used conforms to our naming convention. | None | | Provenance attestation validation / `provenance` | Validates the provenance attestation if the plugin was built with a pipeline supporting provenance attestation (e.g Github Actions). | None | | Published / `published-plugin` | Detects whether any version of this plugin exists in the Grafana plugin catalog currently. | None | +| React 19 Compatibility / `reactcompat` | Detects usage of React APIs removed or deprecated in React 19 using @grafana/react-detect. | None | | Readme (exists) / `readme` | Ensures a `README.md` file exists within the zip file. | None | | Restrictive Dependency / `restrictivedep` | Specifies a valid range of Grafana versions that work with this version of the plugin. | None | | Safe Links / `safelinks` | Checks that links from `plugin.json` are safe. | None | diff --git a/pkg/cmd/plugincheck2/main_test.go b/pkg/cmd/plugincheck2/main_test.go index 916186da..8cd71385 100644 --- a/pkg/cmd/plugincheck2/main_test.go +++ b/pkg/cmd/plugincheck2/main_test.go @@ -159,14 +159,6 @@ func TestIntegration(t *testing.T) { Name: "sponsorshiplink", }, }, - "provenance": { - { - Severity: "recommendation", - Title: "No provenance attestation. This plugin was built without build verification", - Detail: "Cannot verify plugin build. It is recommended to use a pipeline that supports provenance attestation, such as GitHub Actions. https://github.com/grafana/plugin-actions/tree/main/build-plugin", - Name: "no-provenance-attestation", - }, - }, }, }, }, From 9c40676a951cebce1aa127583fa8931081118b2d Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 2 Apr 2026 11:15:26 +0200 Subject: [PATCH 5/5] refactor: add clarifying comments, false-positive note, and stable output order - Add why-comment on prepareTmpDir explaining archive vs dist/ layout - Add why-comment on react19Issue.Disabled explaining it gates all dynamic rules - Sort source code issue patterns for deterministic diagnostic order - Append false-positive disclaimer to all diagnostic details --- .../passes/reactcompat/reactcompat.go | 19 ++++++++++++++----- .../passes/reactcompat/reactcompat_test.go | 2 ++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/pkg/analysis/passes/reactcompat/reactcompat.go b/pkg/analysis/passes/reactcompat/reactcompat.go index 18745073..74fcb2b3 100644 --- a/pkg/analysis/passes/reactcompat/reactcompat.go +++ b/pkg/analysis/passes/reactcompat/reactcompat.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "time" @@ -115,7 +116,8 @@ func run(pass *analysis.Pass) (any, error) { } // prepareTmpDir creates a temporary directory with a dist/ symlink pointing at -// archiveDir. react-detect expects the plugin files to live under dist/. +// archiveDir. The extracted archive has files at the root (module.js, plugin.json, +// etc.) but react-detect expects them under a dist/ subdirectory. // The returned cleanup function removes the temp directory. func prepareTmpDir(archiveDir string) (string, func(), error) { tmpDir, err := os.MkdirTemp("", "reactcompat-*") @@ -175,6 +177,7 @@ func parseResults(data []byte) (*reactDetectOutput, error) { // reportIssues translates the react-detect output into pass diagnostics and // returns the total number of issues reported. func reportIssues(pass *analysis.Pass, output *reactDetectOutput) int { + // react19Issue serves as the config gate for all dynamic react-19 rules. if react19Issue.Disabled { return 0 } @@ -185,14 +188,20 @@ func reportIssues(pass *analysis.Pass, output *reactDetectOutput) int { count := 0 - for _, issues := range output.SourceCodeIssues { - for _, issue := range issues { + patterns := make([]string, 0, len(output.SourceCodeIssues)) + for p := range output.SourceCodeIssues { + patterns = append(patterns, p) + } + slices.Sort(patterns) + + for _, pattern := range patterns { + for _, issue := range output.SourceCodeIssues[pattern] { rule := &analysis.Rule{ Name: fmt.Sprintf("react-19-%s", issue.Pattern), Severity: analysis.Warning, } detail := fmt.Sprintf( - "Detected in %s at line %d. %s See: %s", + "Detected in %s at line %d. %s See: %s Note: this may be a false positive.", issue.Location.File, issue.Location.Line, issue.Fix.Description, @@ -209,7 +218,7 @@ func reportIssues(pass *analysis.Pass, output *reactDetectOutput) int { Severity: analysis.Warning, } detail := fmt.Sprintf( - "Affected packages: %s. See: %s", + "Affected packages: %s. See: %s Note: this may be a false positive.", strings.Join(issue.PackageNames, ", "), issue.Link, ) diff --git a/pkg/analysis/passes/reactcompat/reactcompat_test.go b/pkg/analysis/passes/reactcompat/reactcompat_test.go index a473d894..280190a6 100644 --- a/pkg/analysis/passes/reactcompat/reactcompat_test.go +++ b/pkg/analysis/passes/reactcompat/reactcompat_test.go @@ -122,6 +122,7 @@ func TestReportIssuesSourceCode(t *testing.T) { require.Contains(t, d.Detail, "10") require.Contains(t, d.Detail, "Remove propTypes.") require.Contains(t, d.Detail, "https://react.dev/upgrade") + require.Contains(t, d.Detail, "this may be a false positive") } // TestReportIssuesDependency verifies correct diagnostic generation for @@ -152,6 +153,7 @@ func TestReportIssuesDependency(t *testing.T) { require.Equal(t, "Depends on old react-dom", d.Title) require.Contains(t, d.Detail, "react-dom, react") require.Contains(t, d.Detail, "https://example.com/fix") + require.Contains(t, d.Detail, "this may be a false positive") } // TestReportIssuesNil verifies that a nil output produces no diagnostics.