返回 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 if _, pending := PendingProjectHooks(opts); pending {
69 for i := range out.Entries {
70 out.Entries[i].Issues = append(out.Entries[i].Issues, IssueAwaitingApproval)
71 }
72 }
73 }
74
75 // Plugin hooks (enabled packages only — same as Load).
76 reasonixHomeDir := reasonixHomeForOptions(opts)
77 appendPluginInspect(&out, reasonixHomeDir, opts.ProjectRoot)
78
79 g := filepath.Join(reasonixHomeDir, SettingsFilename)
80 if reasonixHomeDir == "" {
81 g = GlobalSettingsPath(opts.HomeDir)
82 }
83 st := inspectSettingsFile(g, ScopeGlobal)
84 if st.Status == "missing" {
85 if legacy := legacyGlobalSettingsPath(opts.HomeDir); legacy != "" {
86 if pathExists(legacy) {
87 g = legacy
88 st = inspectSettingsFile(g, ScopeGlobal)
89 }
90 }
91 }
92 out.Sources = append(out.Sources, st)
93 if s := readSettingsRaw(g); s != nil {
94 appendInspectEntries(&out, s, ScopeGlobal, g)
95 }
96
97 return out
98 }
99
100 func inspectSettingsFile(path string, scope Scope) SourceStatus {
101 st := SourceStatus{Scope: scope, Path: path}
102 if strings.TrimSpace(path) == "" {
103 st.Status = "missing"
104 return st
105 }
106 b, err := fileencoding.ReadFileUTF8(path)
107 if err != nil {
108 if os.IsNotExist(err) {
109 st.Status = "missing"
110 return st
111 }
112 st.Status = "unreadable"
113 st.ParseError = err.Error()
114 return st
115 }
116 var s Settings
117 if err := json.Unmarshal(b, &s); err != nil {
118 st.Status = "malformed"
119 st.ParseError = err.Error()
120 return st
121 }
122 count := 0
123 for _, hooks := range s.Hooks {
124 count += len(hooks)
125 }
126 st.HookCount = count
127 if count == 0 {
128 st.Status = "empty"
129 } else {
130 st.Status = "ok"
131 }
132 return st
133 }
134
135 func readSettingsRaw(path string) *Settings {
136 return readSettings(path)
137 }
138
139 func appendInspectEntries(out *Inspection, s *Settings, scope Scope, source string) {
140 if s == nil || s.Hooks == nil {
141 return
142 }
143 // Include every map key so misspelled event names remain diagnosable
144 // (hook.unknown_event). Known events first (stable product order), then
145 // remaining keys sorted alphabetically.
146 seen := map[Event]bool{}
147 for _, event := range Events {
148 if hooks, ok := s.Hooks[event]; ok {
149 seen[event] = true
150 for _, cfg := range hooks {
151 out.Entries = append(out.Entries, Entry{
152 Event: event,
153 Match: cfg.Match,
154 Command: cfg.Command,
155 ContextFile: cfg.ContextFile,
156 Description: cfg.Description,
157 Timeout: cfg.Timeout,
158 Scope: scope,
159 Source: source,
160 runtimeConfig: cfg,
161 })
162 }
163 }
164 }
165 var unknown []Event
166 for event := range s.Hooks {
167 if !seen[event] {
168 unknown = append(unknown, event)
169 }
170 }
171 slices.Sort(unknown)
172 for _, event := range unknown {
173 for _, cfg := range s.Hooks[event] {
174 out.Entries = append(out.Entries, Entry{
175 Event: event,
176 Match: cfg.Match,
177 Command: cfg.Command,
178 ContextFile: cfg.ContextFile,
179 Description: cfg.Description,
180 Timeout: cfg.Timeout,
181 Scope: scope,
182 Source: source,
183 runtimeConfig: cfg,
184 })
185 }
186 }
187 }
188
189 func appendPluginInspect(out *Inspection, reasonixHomeDir, projectRoot string) {
190 if strings.TrimSpace(reasonixHomeDir) == "" {
191 return
192 }
193 installed, _ := pluginpkg.LoadInstalled(reasonixHomeDir)
194 for _, item := range installed {
195 pkg := item.Package
196 src := filepath.Join(pkg.Root, pluginpkg.ManifestPath(pkg.ManifestKind))
197 count := 0
198 events := make([]string, 0, len(pkg.Manifest.Hooks))
199 for event := range pkg.Manifest.Hooks {
200 events = append(events, event)
201 }
202 sort.Strings(events)
203 for _, eventName := range events {
204 event := Event(eventName)
205 // Keep unknown event names so diagnostics can report them.
206 for _, h := range pkg.Manifest.Hooks[eventName] {
207 count++
208 execution := pluginHookExecutionConfig(h, pkg.Root)
209 contextFile := expandPluginRoot(h.ContextFile, pkg.Root)
210 if contextFile != "" {
211 contextFile = filepath.FromSlash(contextFile)
212 if !filepath.IsAbs(contextFile) {
213 contextFile = filepath.Join(pkg.Root, contextFile)
214 } else {
215 contextFile = filepath.Clean(contextFile)
216 }
217 }
218 out.Entries = append(out.Entries, Entry{
219 Event: event,
220 Match: h.Match,
221 Command: execution.Command,
222 ContextFile: contextFile,
223 Description: h.Description,
224 Timeout: h.Timeout,
225 Scope: ScopePlugin,
226 Source: src,
227 runtimeConfig: execution,
228 })
229 }
230 }
231 status := "ok"
232 if count == 0 {
233 status = "empty"
234 }
235 out.Sources = append(out.Sources, SourceStatus{
236 Scope: ScopePlugin,
237 Path: src,
238 Status: status,
239 HookCount: count,
240 })
241 _ = projectRoot
242 }
243 }
244
245 // CheckEntryRuntime validates the host dependencies required by an inspected
246 // Hook without executing the command.
247 func CheckEntryRuntime(entry Entry, options RuntimeOptions) error {
248 return CheckRuntime(entry.runtimeConfig, options)
249 }
250
251 // ValidateMatcher returns an error string when match is an invalid anchored regex.
252 // Empty and "*" are valid (match all).
253 func ValidateMatcher(match string) string {
254 m := strings.TrimSpace(match)
255 if m == "" || m == "*" {
256 return ""
257 }
258 if _, err := regexp.Compile("^(?:" + m + ")$"); err != nil {
259 return fmt.Sprintf("invalid matcher regex: %v", err)
260 }
261 return ""
262 }
263
264 // UsesToolMatcher reports whether an event evaluates HookConfig.Match.
265 // Non-tool events ignore matchers entirely, including malformed ones.
266 func UsesToolMatcher(event Event) bool {
267 return event == PreToolUse || event == PostToolUse || event == PostToolUseFailure || event == PermissionRequest
268 }
269
270 // IsKnownEvent reports whether event is one of the 11 supported hook events.
271 func IsKnownEvent(event string) bool {
272 return validEvent(Event(event))
273 }
274
274 lines GO