-
Notifications
You must be signed in to change notification settings - Fork 8
React19: Add a validator to check plugin compatibility #552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
65c1612
feat: add React 19 compatibility checker for plugins
leventebalogh 90d8057
refactor: rewrite reactcompat analyzer to use @grafana/react-detect
leventebalogh 1835045
fix: add missing provenance expectation in integration test
leventebalogh 85d8c52
fix: add reactcompat to README analyzers table
leventebalogh 973bf30
refactor: add clarifying comments, false-positive note, and stable ou…
leventebalogh fb7191e
refactor: use --distDir flag instead of symlink workaround
leventebalogh 1b4df8d
refactor: rewrite reactcompat analyzer to use @grafana/react-detect
leventebalogh 996d7e4
fix: declare npx dependency and log stderr on success
leventebalogh 03cb92f
fix: make TestNpxNotAvailable deterministic
leventebalogh 509b8d1
fix: inherit severity from react19Issue config for dynamic rules
leventebalogh b1c711e
docs: broaden Analyzer doc comment to cover all skip scenarios
leventebalogh 47423c4
fix: pin @grafana/react-detect to v0.6.4 for reproducibility
leventebalogh ce26fa0
fix: emit warning diagnostics instead of silently skipping
leventebalogh 6063b42
fix: only emit warning when react-detect fails, not when npx is absent
leventebalogh 98f70a1
refactor: extract react-detect version to package-level const
leventebalogh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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) { | ||
|
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", | ||
| } | ||
|
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( | ||
|
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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.