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