Files
2026-07-13 13:00:08 +08:00

257 lines
6.8 KiB
Go

package hook
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/pluginpkg"
)
// SourceStatus describes one hooks settings file for diagnostics.
type SourceStatus struct {
Scope Scope
Path string
Status string // ok | missing | malformed | untrusted_skipped | empty
HookCount int
ParseError string
}
// Entry is one configured hook with diagnostic annotations.
type Entry struct {
Event Event
Match string
Command string
ContextFile string
Description string
Timeout int
Scope Scope
Source string
Issues []string // stable codes attached at collect time (optional)
}
// Inspection is a read-only hook configuration snapshot. It does not execute
// hooks and does not mutate trust state.
type Inspection struct {
TrustedProject bool
Sources []SourceStatus
Entries []Entry
// ProjectDefines is true when project settings declare hooks regardless of trust.
ProjectDefines bool
}
// Inspect loads hook configuration for diagnostics. Unlike Load, it reports
// malformed files, untrusted project files, and empty/missing command entries
// that Load would silently skip.
func Inspect(opts LoadOptions) Inspection {
out := Inspection{
TrustedProject: opts.ProjectRoot != "" && IsTrusted(opts.ProjectRoot, opts.HomeDir),
ProjectDefines: opts.ProjectRoot != "" && ProjectDefinesHooks(opts.ProjectRoot),
}
if opts.ProjectRoot != "" {
p := ProjectSettingsPath(opts.ProjectRoot)
if !opts.Trusted && !out.TrustedProject {
// Report project file even when untrusted so UIs can surface trust.
st := inspectSettingsFile(p, ScopeProject)
if st.Status == "ok" || st.Status == "malformed" || st.Status == "empty" {
st.Status = "untrusted_skipped"
}
out.Sources = append(out.Sources, st)
// Still parse entries for display, tagged as untrusted source.
if s := readSettingsRaw(p); s != nil {
appendInspectEntries(&out, s, ScopeProject, p)
}
} else {
st := inspectSettingsFile(p, ScopeProject)
out.Sources = append(out.Sources, st)
if s := readSettingsRaw(p); s != nil {
appendInspectEntries(&out, s, ScopeProject, p)
}
}
}
// Plugin hooks (enabled packages only — same as Load).
appendPluginInspect(&out, reasonixHome(opts.HomeDir), opts.ProjectRoot)
g := GlobalSettingsPath(opts.HomeDir)
st := inspectSettingsFile(g, ScopeGlobal)
if st.Status == "missing" {
if legacy := legacyGlobalSettingsPath(opts.HomeDir); legacy != "" {
if pathExists(legacy) {
g = legacy
st = inspectSettingsFile(g, ScopeGlobal)
}
}
}
out.Sources = append(out.Sources, st)
if s := readSettingsRaw(g); s != nil {
appendInspectEntries(&out, s, ScopeGlobal, g)
}
return out
}
func inspectSettingsFile(path string, scope Scope) SourceStatus {
st := SourceStatus{Scope: scope, Path: path}
if strings.TrimSpace(path) == "" {
st.Status = "missing"
return st
}
b, err := fileencoding.ReadFileUTF8(path)
if err != nil {
if os.IsNotExist(err) {
st.Status = "missing"
return st
}
st.Status = "unreadable"
st.ParseError = err.Error()
return st
}
var s Settings
if err := json.Unmarshal(b, &s); err != nil {
st.Status = "malformed"
st.ParseError = err.Error()
return st
}
count := 0
for _, hooks := range s.Hooks {
count += len(hooks)
}
st.HookCount = count
if count == 0 {
st.Status = "empty"
} else {
st.Status = "ok"
}
return st
}
func readSettingsRaw(path string) *Settings {
return readSettings(path)
}
func appendInspectEntries(out *Inspection, s *Settings, scope Scope, source string) {
if s == nil || s.Hooks == nil {
return
}
// Include every map key so misspelled event names remain diagnosable
// (hook.unknown_event). Known events first (stable product order), then
// remaining keys sorted alphabetically.
seen := map[Event]bool{}
for _, event := range Events {
if hooks, ok := s.Hooks[event]; ok {
seen[event] = true
for _, cfg := range hooks {
out.Entries = append(out.Entries, Entry{
Event: event,
Match: cfg.Match,
Command: cfg.Command,
ContextFile: cfg.ContextFile,
Description: cfg.Description,
Timeout: cfg.Timeout,
Scope: scope,
Source: source,
})
}
}
}
var unknown []Event
for event := range s.Hooks {
if !seen[event] {
unknown = append(unknown, event)
}
}
sort.Slice(unknown, func(i, j int) bool { return unknown[i] < unknown[j] })
for _, event := range unknown {
for _, cfg := range s.Hooks[event] {
out.Entries = append(out.Entries, Entry{
Event: event,
Match: cfg.Match,
Command: cfg.Command,
ContextFile: cfg.ContextFile,
Description: cfg.Description,
Timeout: cfg.Timeout,
Scope: scope,
Source: source,
})
}
}
}
func appendPluginInspect(out *Inspection, reasonixHomeDir, projectRoot string) {
if strings.TrimSpace(reasonixHomeDir) == "" {
return
}
installed, _ := pluginpkg.LoadInstalled(reasonixHomeDir)
for _, item := range installed {
pkg := item.Package
src := filepath.Join(pkg.Root, pluginpkg.ManifestPath(pkg.ManifestKind))
count := 0
events := make([]string, 0, len(pkg.Manifest.Hooks))
for event := range pkg.Manifest.Hooks {
events = append(events, event)
}
sort.Strings(events)
for _, eventName := range events {
event := Event(eventName)
// Keep unknown event names so diagnostics can report them.
for _, h := range pkg.Manifest.Hooks[eventName] {
count++
command := h.Command
if command != "" && !h.ShellCommand && !filepath.IsAbs(command) {
command = filepath.Join(pkg.Root, filepath.FromSlash(command))
}
contextFile := h.ContextFile
if contextFile != "" && !filepath.IsAbs(contextFile) {
contextFile = filepath.Join(pkg.Root, filepath.FromSlash(contextFile))
}
out.Entries = append(out.Entries, Entry{
Event: event,
Match: h.Match,
Command: command,
ContextFile: contextFile,
Description: h.Description,
Timeout: h.Timeout,
Scope: ScopePlugin,
Source: src,
})
}
}
status := "ok"
if count == 0 {
status = "empty"
}
out.Sources = append(out.Sources, SourceStatus{
Scope: ScopePlugin,
Path: src,
Status: status,
HookCount: count,
})
_ = projectRoot
}
}
// ValidateMatcher returns an error string when match is an invalid anchored regex.
// Empty and "*" are valid (match all).
func ValidateMatcher(match string) string {
m := strings.TrimSpace(match)
if m == "" || m == "*" {
return ""
}
if _, err := regexp.Compile("^(?:" + m + ")$"); err != nil {
return fmt.Sprintf("invalid matcher regex: %v", err)
}
return ""
}
// IsKnownEvent reports whether event is one of the 11 supported hook events.
func IsKnownEvent(event string) bool {
return validEvent(Event(event))
}