Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions pkg/analysis/passes/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -90,6 +91,7 @@ var Analyzers = []*analysis.Analyzer{
pluginname.Analyzer,
provenance.Analyzer,
published.Analyzer,
reactcompat.Analyzer,
readme.Analyzer,
restrictivedep.Analyzer,
screenshots.Analyzer,
Expand Down
230 changes: 230 additions & 0 deletions pkg/analysis/passes/reactcompat/reactcompat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package reactcompat

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"

"github.com/grafana/plugin-validator/pkg/analysis"
"github.com/grafana/plugin-validator/pkg/analysis/passes/archive"
"github.com/grafana/plugin-validator/pkg/logme"
)

var (
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{archive.Analyzer},
Run: run,
Rules: []*analysis.Rule{react19Issue, react19Compatible},
ReadmeInfo: analysis.ReadmeInfo{
Name: "React 19 Compatibility",
Description: "Detects usage of React APIs removed or deprecated in React 19 using @grafana/react-detect.",
},
}

// 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 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"`
}

type location struct {
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
}

type fix struct {
Description string `json:"description"`
}

type dependencyIssue struct {
Pattern string `json:"pattern"`
Severity string `json:"severity"`
Problem string `json:"problem"`
Link string `json:"link"`
PackageNames []string `json:"packageNames"`
}

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
}

// prepareTmpDir creates a temporary directory with a dist/ symlink pointing at
// 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-*")
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
}

// 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)
}

// 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
}

// 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
}

if output == nil {
return 0
}

count := 0

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 Note: this may be a false positive.",
issue.Location.File,
issue.Location.Line,
issue.Fix.Description,
issue.Link,
)
pass.ReportResult(pass.AnalyzerName, rule, issue.Problem, detail)
count++
}
}

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 Note: this may be a false positive.",
strings.Join(issue.PackageNames, ", "),
issue.Link,
)
pass.ReportResult(pass.AnalyzerName, rule, issue.Problem, detail)
count++
}

return count
}
Loading
Loading