Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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. | [npx](https://docs.npmjs.com/cli/v10/commands/npx) |
| 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
219 changes: 219 additions & 0 deletions pkg/analysis/passes/reactcompat/reactcompat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package reactcompat

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"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"
)

// reactDetectVersion is the pinned version of @grafana/react-detect.
// Bump intentionally when adopting new detection rules.
const reactDetectVersion = "0.6.4"

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. Silently skips if npx is not in PATH.
// If react-detect is found but fails, a warning diagnostic is emitted.
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.",
Dependencies: "[npx](https://docs.npmjs.com/cli/v10/commands/npx)",
},
}

// 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 {
// npx not in PATH is expected in environments without Node.js (e.g. Docker builder).
// Only log at debug level — not a failure, just an unavailable optional check.
logme.DebugFln("npx not found in PATH, skipping react-detect")
return nil, nil
}
logme.DebugFln("npx path: %s", npxPath)

output, err := runReactDetect(npxPath, archiveDir)
if err != nil {
logme.DebugFln("react-detect failed: %v", err)
pass.ReportResult(
pass.AnalyzerName,
react19Issue,
"React 19 compatibility: skipped (react-detect failed)",
fmt.Sprintf("react-detect could not be executed: %v", err),
)
return nil, nil
}
Comment thread
leventebalogh marked this conversation as resolved.

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
}

// runReactDetect shells out to react-detect and returns the parsed output.
// --distDir points react-detect directly at the extracted archive directory,
// avoiding the need for a symlink or temp directory.
func runReactDetect(npxPath, archiveDir string) (*reactDetectOutput, error) {
Comment thread
leventebalogh marked this conversation as resolved.
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.
// --distDir: point at the extracted archive directly (available since v0.6.4).
// Dependency issues are intentionally included (no --skipDependencies).
args := []string{
"-y",
"@grafana/react-detect@" + reactDetectVersion,
"--json",
"--distDir", archiveDir,
"--skipBuildTooling",
"--noErrorExitCode",
}
Comment thread
leventebalogh marked this conversation as resolved.
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 stderr.Len() > 0 {
logme.DebugFln("react-detect stderr: %s", stderr.String())
}
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: react19Issue.Severity,
}
detail := fmt.Sprintf(
Comment thread
leventebalogh marked this conversation as resolved.
"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, "React 19 compatibility: "+issue.Problem, detail)
count++
}
}

for _, issue := range output.DependencyIssues {
rule := &analysis.Rule{
Name: fmt.Sprintf("react-19-dep-%s", issue.Pattern),
Severity: react19Issue.Severity,
}
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, "React 19 compatibility: "+issue.Problem, detail)
count++
}

return count
}
Loading
Loading