257 lines
6.8 KiB
Go
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))
|
|
}
|