fix(studio): report plugin hook declaration warnings

This commit is contained in:
xiexian committed 2026-10-02 17:17:59 +08:00
1 parent 921e7bdb95
commit 7a8dfa5499
5 files changed
+161

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
package hook
import (
"fmt"
"maps"
"slices"
"reasonix/internal/ext/pluginpkg"
)
// PackageWarnings diagnoses hook declarations without consulting the enabled
// registry or executing commands.
func PackageWarnings(pkg pluginpkg.Package) []string {
var warnings []string
for _, eventName := range slices.Sorted(maps.Keys(pkg.Manifest.Hooks)) {
if !IsKnownEvent(eventName) {
warnings = append(warnings, fmt.Sprintf("hooks.%s: unknown event; these hooks will not run", eventName))
continue
}
if !UsesToolMatcher(Event(eventName)) {
continue
}
for i, entry := range pkg.Manifest.Hooks[eventName] {
if warning := ValidateMatcher(entry.Match); warning != "" {
warnings = append(warnings, fmt.Sprintf("hooks.%s[%d]: %s", eventName, i, warning))
}
}
}
return warnings
}
+1
View File
@@ -465,6 +465,7 @@ func pluginDoctorCommand(args []string) int {
}
}
warnings = append(warnings, theme.PluginWarnings(pkg)...)
warnings = append(warnings, hook.PackageWarnings(pkg)...)
for _, warning := range warnings {
fmt.Println("warning:", warning)
}
@@ -0,0 +1,59 @@
package cli
import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/ext/pluginpkg"
)
func TestPluginDoctorReportsHookDeclarationWarningsWhenDisabled(t *testing.T) {
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
root := filepath.Join(home, "plugins", "hook-warning-kit")
const manifest = `{"apiVersion":"reasonix.io/plugin/v2","name":"hook-warning-kit","contributes":{"hooks":{
"SesionStart":[{"contextFile":"note.md"}],
"PreToolUse":[{"contextFile":"note.md","match":"["},{"contextFile":"note.md","match":"*"}],
"PostToolUse":[{"contextFile":"note.md","match":"["}],
"PostToolUseFailure":[{"contextFile":"note.md","match":"["}],
"PermissionRequest":[{"contextFile":"note.md","match":"["}],
"SessionStart":[{"contextFile":"note.md","match":"["}]
}}}`
path := filepath.Join(root, pluginpkg.NativeManifest)
writePluginTestFile(t, path, manifest)
writePluginTestFile(t, filepath.Join(root, "note.md"), "Local author note")
for _, enabled := range []bool{true, false} {
if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{Name: "hook-warning-kit", Root: "plugins/hook-warning-kit", ManifestKind: "reasonix", Enabled: enabled}); err != nil {
t.Fatal(err)
}
out := captureStdout(t, func() {
if rc := pluginCommand([]string{"doctor", "hook-warning-kit"}); rc != 0 {
t.Fatalf("doctor rc=%d", rc)
}
})
if strings.Count(out, "warning:") != 5 {
t.Fatalf("enabled=%v doctor=%s", enabled, out)
}
for _, event := range []string{"PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest"} {
if !strings.Contains(out, "hooks."+event+"[0]: invalid matcher regex") {
t.Errorf("enabled=%v missing %s warning: %s", enabled, event, out)
}
}
if !strings.Contains(out, "hooks.SesionStart: unknown event") || strings.Contains(out, "hooks.SessionStart") {
t.Fatalf("unknown or non-tool event warnings incorrect: %s", out)
}
}
corrected := strings.Replace(manifest, `"SesionStart"`, `"Stop"`, 1)
corrected = strings.ReplaceAll(corrected, `"match":"["`, `"match":"read_file"`)
writePluginTestFile(t, path, corrected)
out := captureStdout(t, func() {
if rc := pluginCommand([]string{"doctor", "hook-warning-kit"}); rc != 0 {
t.Fatalf("corrected doctor rc=%d", rc)
}
})
if strings.Contains(out, "warning:") {
t.Fatalf("corrected declarations still have warnings: %s", out)
}
}
+1
View File
@@ -121,6 +121,7 @@ func pluginViewFor(home, workspaceRoot string, p pluginpkg.InstalledPlugin) plug
return view
}
view.Warnings = append(warnings, theme.PluginWarnings(pkg)...)
view.Warnings = append(view.Warnings, hook.PackageWarnings(pkg)...)
view.Compatibility = pkg.Compatibility.Status
view.Skipped = pkg.Compatibility.Skipped
@@ -0,0 +1,70 @@
package serve
import (
"net/http"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestInstalledPluginReportsHookDeclarationWarnings(t *testing.T) {
_, _, base := pluginHome(t)
source := testenv.TempDir(t)
const manifest = `{"apiVersion":"reasonix.io/plugin/v2","name":"hook-warning-kit","contributes":{"hooks":{
"SesionStart":[{"contextFile":"note.md","match":"["}],
"PreToolUse":[{"contextFile":"note.md","match":"["}],
"SessionStart":[{"contextFile":"note.md","match":"["}]
}}}`
writePluginFile(t, filepath.Join(source, "reasonix-plugin.json"), manifest)
writePluginFile(t, filepath.Join(source, "note.md"), "Local author note")
install := func(replace bool) {
t.Helper()
args := map[string]any{"source": source, "replace": replace}
plan := postJSON(t, base+"/plugins/plan", args)
body := decodeInstallSource(t, plan)
if plan.StatusCode != http.StatusOK || body["planId"] == "" {
t.Fatalf("plan status=%d: %v", plan.StatusCode, body)
}
args["planId"] = body["planId"]
resp := postJSON(t, base+"/plugins/install", args)
if body := decodeInstallSource(t, resp); resp.StatusCode != http.StatusOK || body["status"] != "done" {
t.Fatalf("install status=%d: %v", resp.StatusCode, body)
}
}
read := func() []string {
t.Helper()
plugins := getPlugins(t, base)
if len(plugins) != 1 || plugins[0].Name != "hook-warning-kit" || len(plugins[0].Hooks) != 3 || plugins[0].Error != "" {
t.Fatalf("plugins=%+v", plugins)
}
return plugins[0].Warnings
}
install(false)
warnings := read()
if len(warnings) != 2 || !strings.Contains(warnings[0], "hooks.PreToolUse[0]: invalid matcher regex") || !strings.Contains(warnings[1], `hooks.SesionStart: unknown event`) {
t.Fatalf("hook warnings=%v", warnings)
}
for _, enabled := range []bool{false, true} {
resp := postJSON(t, base+"/plugins/enabled", map[string]any{"name": "hook-warning-kit", "enabled": enabled})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("enabled status=%d", resp.StatusCode)
}
if got := read(); !slices.Equal(got, warnings) {
t.Fatalf("enabled=%v warnings=%v", enabled, got)
}
}
corrected := strings.Replace(manifest, `"SesionStart"`, `"Stop"`, 1)
corrected = strings.ReplaceAll(corrected, `"match":"["`, `"match":"read_file"`)
writePluginFile(t, filepath.Join(source, "reasonix-plugin.json"), corrected)
if got := read(); !slices.Equal(got, warnings) {
t.Fatal("source edits changed the installed copy's warnings")
}
install(true)
if got := read(); len(got) != 0 {
t.Fatalf("corrected replacement warnings=%v", got)
}
}