返回 DeepSeek-Reasonix
inspect.go
根目录 / internal / hook / inspect.go
1 package hook
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "regexp"
9 "sort"
10 "strings"
11
12 fileencoding "reasonix/internal/fileutil/encoding"
13 "reasonix/internal/pluginpkg"
14 )
15
16 // SourceStatus describes one hooks settings file for diagnostics.
17 type SourceStatus struct {
18 Scope Scope
19 Path string
20 Status string // ok | missing | malformed | empty
21 HookCount int
22 ParseError string
23 }
24
25 // Entry is one configured hook with diagnostic annotations.
26 type Entry struct {
27 Event Event
28 Match string
29 Command string
30 ContextFile string
31 Description string
32 Timeout int
33 Scope Scope
34 Source string
35 Issues []string // stable codes attached at collect time (optional)
36 runtimeConfig HookConfig
37 }
38
39 // Inspection is a read-only hook configuration snapshot. It does not execute
40 // hooks.
41 type Inspection struct {
42 // TrustedProject is retained in diagnostics for backward compatibility.
43 // A non-empty project root is always trusted now.
44 TrustedProject bool
45 Sources []SourceStatus
46 Entries []Entry
47 // ProjectDefines is true when project settings declare hooks.
48 ProjectDefines bool
49 }
50
51 // Inspect loads hook configuration for diagnostics. Unlike Load, it reports
52 // malformed files and empty/missing command entries that Load would silently
53 // skip.
54 func Inspect(opts LoadOptions) Inspection {
55 out := Inspection{
56 TrustedProject: opts.ProjectRoot != "",
57 ProjectDefines: opts.ProjectRoot != "" && ProjectDefinesHooks(opts.ProjectRoot),
58 }
59
60 if opts.ProjectRoot != "" {
61 p := ProjectSettingsPath(opts.ProjectRoot)
62 st := inspectSettingsFile(p, ScopeProject)
63 out.Sources = append(out.Sources, st)
64 if s := readSettingsRaw(p); s != nil {
65 appendInspectEntries(&out, s, ScopeProject, p)
66 }
67 }
68
69 // Plugin hooks (enabled packages only — same as Load).
70 reasonixHomeDir := reasonixHomeForOptions(opts)
71 appendPluginInspect(&out, reasonixHomeDir, opts.ProjectRoot)
72
73 g := filepath.Join(reasonixHomeDir, SettingsFilename)
74 if reasonixHomeDir == "" {
75 g = GlobalSettingsPath(opts.HomeDir)
76 }
77 st := inspectSettingsFile(g, ScopeGlobal)
78 if st.Status == "missing" {
79 if legacy := legacyGlobalSettingsPath(opts.HomeDir); legacy != "" {
80 if pathExists(legacy) {
81 g = legacy
82 st = inspectSettingsFile(g, ScopeGlobal)
83 }
84 }
85 }
86 out.Sources = append(out.Sources, st)
87 if s := readSettingsRaw(g); s != nil {
88 appendInspectEntries(&out, s, ScopeGlobal, g)
89 }
90
91 return out
92 }
93
94 func inspectSettingsFile(path string, scope Scope) SourceStatus {
95 st := SourceStatus{Scope: scope, Path: path}
96 if strings.TrimSpace(path) == "" {
97 st.Status = "missing"
98 return st
99 }
100 b, err := fileencoding.ReadFileUTF8(path)
101 if err != nil {
102 if os.IsNotExist(err) {
103 st.Status = "missing"
104 return st
105 }
106 st.Status = "unreadable"
107 st.ParseError = err.Error()
108 return st
109 }
110 var s Settings
111 if err := json.Unmarshal(b, &s); err != nil {
112 st.Status = "malformed"
113 st.ParseError = err.Error()
114 return st
115 }
116 count := 0
117 for _, hooks := range s.Hooks {
118 count += len(hooks)
119 }
120 st.HookCount = count
121 if count == 0 {
122 st.Status = "empty"
123 } else {
124 st.Status = "ok"
125 }
126 return st
127 }
128
129 func readSettingsRaw(path string) *Settings {
130 return readSettings(path)
131 }
132
133 func appendInspectEntries(out *Inspection, s *Settings, scope Scope, source string) {
134 if s == nil || s.Hooks == nil {
135 return
136 }
137 // Include every map key so misspelled event names remain diagnosable
138 // (hook.unknown_event). Known events first (stable product order), then
139 // remaining keys sorted alphabetically.
140 seen := map[Event]bool{}
141 for _, event := range Events {
142 if hooks, ok := s.Hooks[event]; ok {
143 seen[event] = true
144 for _, cfg := range hooks {
145 out.Entries = append(out.Entries, Entry{
146 Event: event,
147 Match: cfg.Match,
148 Command: cfg.Command,
149 ContextFile: cfg.ContextFile,
150 Description: cfg.Description,
151 Timeout: cfg.Timeout,
152 Scope: scope,
153 Source: source,
154 runtimeConfig: cfg,
155 })
156 }
157 }
158 }
159 var unknown []Event
160 for event := range s.Hooks {
161 if !seen[event] {
162 unknown = append(unknown, event)
163 }
164 }
165 sort.Slice(unknown, func(i, j int) bool { return unknown[i] < unknown[j] })
166 for _, event := range unknown {
167 for _, cfg := range s.Hooks[event] {
168 out.Entries = append(out.Entries, Entry{
169 Event: event,
170 Match: cfg.Match,
171 Command: cfg.Command,
172 ContextFile: cfg.ContextFile,
173 Description: cfg.Description,
174 Timeout: cfg.Timeout,
175 Scope: scope,
176 Source: source,
177 runtimeConfig: cfg,
178 })
179 }
180 }
181 }
182
183 func appendPluginInspect(out *Inspection, reasonixHomeDir, projectRoot string) {
184 if strings.TrimSpace(reasonixHomeDir) == "" {
185 return
186 }
187 installed, _ := pluginpkg.LoadInstalled(reasonixHomeDir)
188 for _, item := range installed {
189 pkg := item.Package
190 src := filepath.Join(pkg.Root, pluginpkg.ManifestPath(pkg.ManifestKind))
191 count := 0
192 events := make([]string, 0, len(pkg.Manifest.Hooks))
193 for event := range pkg.Manifest.Hooks {
194 events = append(events, event)
195 }
196 sort.Strings(events)
197 for _, eventName := range events {
198 event := Event(eventName)
199 // Keep unknown event names so diagnostics can report them.
200 for _, h := range pkg.Manifest.Hooks[eventName] {
201 count++
202 execution := pluginHookExecutionConfig(h, pkg.Root)
203 contextFile := expandPluginRoot(h.ContextFile, pkg.Root)
204 if contextFile != "" {
205 contextFile = filepath.FromSlash(contextFile)
206 if !filepath.IsAbs(contextFile) {
207 contextFile = filepath.Join(pkg.Root, contextFile)
208 } else {
209 contextFile = filepath.Clean(contextFile)
210 }
211 }
212 out.Entries = append(out.Entries, Entry{
213 Event: event,
214 Match: h.Match,
215 Command: execution.Command,
216 ContextFile: contextFile,
217 Description: h.Description,
218 Timeout: h.Timeout,
219 Scope: ScopePlugin,
220 Source: src,
221 runtimeConfig: execution,
222 })
223 }
224 }
225 status := "ok"
226 if count == 0 {
227 status = "empty"
228 }
229 out.Sources = append(out.Sources, SourceStatus{
230 Scope: ScopePlugin,
231 Path: src,
232 Status: status,
233 HookCount: count,
234 })
235 _ = projectRoot
236 }
237 }
238
239 // CheckEntryRuntime validates the host dependencies required by an inspected
240 // Hook without executing the command.
241 func CheckEntryRuntime(entry Entry, options RuntimeOptions) error {
242 return CheckRuntime(entry.runtimeConfig, options)
243 }
244
245 // ValidateMatcher returns an error string when match is an invalid anchored regex.
246 // Empty and "*" are valid (match all).
247 func ValidateMatcher(match string) string {
248 m := strings.TrimSpace(match)
249 if m == "" || m == "*" {
250 return ""
251 }
252 if _, err := regexp.Compile("^(?:" + m + ")$"); err != nil {
253 return fmt.Sprintf("invalid matcher regex: %v", err)
254 }
255 return ""
256 }
257
258 // UsesToolMatcher reports whether an event evaluates HookConfig.Match.
259 // Non-tool events ignore matchers entirely, including malformed ones.
260 func UsesToolMatcher(event Event) bool {
261 return event == PreToolUse || event == PostToolUse || event == PostToolUseFailure || event == PermissionRequest
262 }
263
264 // IsKnownEvent reports whether event is one of the 11 supported hook events.
265 func IsKnownEvent(event string) bool {
266 return validEvent(Event(event))
267 }
268
268 lines GO