返回 DeepSeek-Reasonix
collect_test.go
根目录 / internal / capdiag / collect_test.go
1 package capdiag_test
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10
11 "reasonix/internal/capdiag"
12 "reasonix/internal/pluginpkg"
13 )
14
15 func TestCollectStaticNoNetworkSideEffects(t *testing.T) {
16 root := t.TempDir()
17 home := t.TempDir()
18 t.Setenv("HOME", home)
19 t.Setenv("USERPROFILE", home)
20 t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
21 // Shadowed skill + missing description + command override.
22 write(t, filepath.Join(root, ".reasonix", "skills", "demo", "SKILL.md"),
23 "---\nname: demo\ndescription: project demo\n---\nbody\n")
24 write(t, filepath.Join(home, ".reasonix", "skills", "demo", "SKILL.md"),
25 "---\nname: demo\ndescription: global demo\n---\nbody\n")
26 write(t, filepath.Join(root, ".reasonix", "skills", "nodesc", "SKILL.md"),
27 "---\nname: nodesc\n---\nbody\n")
28 write(t, filepath.Join(root, ".reasonix", "commands", "hi.md"),
29 "---\ndescription: project hi\n---\nP $ARGUMENTS\n")
30 write(t, filepath.Join(home, ".reasonix", "commands", "hi.md"),
31 "---\ndescription: home hi\n---\nH $ARGUMENTS\n")
32
33 // Project hooks load automatically.
34 write(t, filepath.Join(root, ".reasonix", "settings.json"), `{
35 "hooks": {
36 "PreToolUse": [{"match": "(", "command": "echo bad"}, {"match": ".*", "command": "echo ok"}]
37 }
38 }`)
39
40 // MCP with missing command.
41 write(t, filepath.Join(root, "reasonix.toml"), `
42 [[plugins]]
43 name = "broken"
44 type = "stdio"
45 command = "definitely-not-a-real-binary-xyzzy-reasonix"
46 auto_start = false
47 `)
48
49 // Instruction file.
50 write(t, filepath.Join(root, "AGENTS.md"), "# Agents\nUse go test.\n@../secret.md\n")
51
52 r := capdiag.Collect(capdiag.Options{
53 Root: root,
54 HomeDir: home,
55 ReasonixHomeDir: filepath.Join(home, ".reasonix"),
56 Live: false,
57 })
58
59 if r.SchemaVersion != 1 {
60 t.Fatalf("schema = %d", r.SchemaVersion)
61 }
62 if r.Live {
63 t.Fatal("static mode must set live=false")
64 }
65 if len(r.MCP.Servers) != 1 {
66 t.Fatalf("MCP servers = %+v, want one effective entry", r.MCP.Servers)
67 }
68 mcp := r.MCP.Servers[0]
69 if !mcp.Effective || mcp.Source != "project_config" || mcp.SourcePath != "<workspace>/reasonix.toml" {
70 t.Fatalf("effective MCP provenance = %+v", mcp)
71 }
72 // Missing convention dirs should not produce warnings.
73 for _, is := range r.Issues {
74 if strings.Contains(is.Message, "missing") && is.Subsystem == "skills" && is.Code != "skill.missing_description" {
75 t.Fatalf("unexpected missing-dir issue: %+v", is)
76 }
77 }
78
79 codes := map[string]bool{}
80 for _, is := range r.Issues {
81 codes[is.Code] = true
82 }
83 for _, want := range []string{
84 "skill.shadowed", "skill.missing_description", "command.shadowed",
85 "hook.invalid_matcher", "mcp.command_not_found", "instruction.import_outside_source",
86 } {
87 if !codes[want] {
88 t.Fatalf("missing issue code %s in %+v", want, codes)
89 }
90 }
91
92 // Path redaction: no raw home username paths.
93 raw, err := json.Marshal(r)
94 if err != nil {
95 t.Fatal(err)
96 }
97 text := string(raw) + capdiag.RenderText(r)
98 if strings.Contains(text, home) {
99 t.Fatalf("report leaked home path %q", home)
100 }
101 if strings.Contains(text, root) && !strings.Contains(text, "<workspace>") {
102 // Root itself is rewritten to <workspace>; full root abs path must not appear.
103 t.Fatalf("report leaked workspace abs path")
104 }
105 if strings.Contains(text, "token=") || strings.Contains(text, "Bearer ") {
106 t.Fatal("report leaked secret-like material")
107 }
108
109 // Deterministic JSON round.
110 j1, _ := capdiag.RenderJSON(r)
111 r2 := capdiag.Collect(capdiag.Options{
112 Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"),
113 })
114 j2, _ := capdiag.RenderJSON(r2)
115 if j1 != j2 {
116 t.Fatal("JSON report not deterministic")
117 }
118
119 // Instructions listed.
120 if len(r.Instructions.Docs) == 0 {
121 t.Fatal("expected AGENTS.md in instructions")
122 }
123 }
124
125 func TestCollectUsesExactReasonixHomeForGlobalHooks(t *testing.T) {
126 root := t.TempDir()
127 home := t.TempDir()
128 reasonixHome := filepath.Join(home, "AppData", "Roaming", "reasonix")
129 write(t, filepath.Join(reasonixHome, "settings.json"), `{
130 "hooks": {
131 "SessionStart": [{"command": "echo exact-home"}]
132 }
133 }`)
134
135 report := capdiag.Collect(capdiag.Options{
136 Root: root,
137 HomeDir: home,
138 ReasonixHomeDir: reasonixHome,
139 })
140 if report.Summary.Hooks != 1 || len(report.Hooks.Entries) != 1 {
141 t.Fatalf("hooks = %+v, summary = %+v", report.Hooks, report.Summary)
142 }
143 for _, source := range report.Hooks.Sources {
144 if source.Scope == "global" {
145 if source.Status != "ok" || source.HookCount != 1 {
146 t.Fatalf("global hook source = %+v, want exact Reasonix home settings", source)
147 }
148 if source.Path != "<reasonix-home>/settings.json" && source.Path != "<reasonix-home>\\settings.json" {
149 // displayPath uses ToSlash
150 if !strings.Contains(source.Path, "<reasonix-home>") {
151 t.Fatalf("global path = %q, want <reasonix-home> prefix", source.Path)
152 }
153 }
154 return
155 }
156 }
157 t.Fatal("global hook source not reported")
158 }
159
160 func TestMissingConventionDirsNoWarning(t *testing.T) {
161 root := t.TempDir()
162 home := t.TempDir()
163 t.Setenv("HOME", home)
164 t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
165 r := capdiag.Collect(capdiag.Options{
166 Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"),
167 })
168 if r.Issues == nil {
169 t.Fatal("empty issues must be a non-nil slice for JSON consumers")
170 }
171 raw, err := json.Marshal(r)
172 if err != nil {
173 t.Fatal(err)
174 }
175 if !strings.Contains(string(raw), `"issues":[]`) {
176 t.Fatalf("empty issues must marshal as [], got %s", raw)
177 }
178 for _, is := range r.Issues {
179 if is.Severity == "warning" && strings.Contains(strings.ToLower(is.Message), "directory") {
180 t.Fatalf("missing dir should not warn: %+v", is)
181 }
182 }
183 // Roots may be missing; that is a normal status, not an issue.
184 _ = r.Skills.Roots
185 }
186
187 func TestProjectHooksEnabledByDefault(t *testing.T) {
188 root := t.TempDir()
189 home := t.TempDir()
190 t.Setenv("HOME", home)
191 t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
192 write(t, filepath.Join(root, ".reasonix", "settings.json"), `{
193 "hooks": {"Stop": [{"command": "echo done"}]}
194 }`)
195 r := capdiag.Collect(capdiag.Options{
196 Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"),
197 })
198 if !r.Hooks.TrustedProject {
199 t.Fatal("compatibility field trusted_project should reflect default enablement")
200 }
201 if len(r.Hooks.Entries) != 1 || r.Hooks.Entries[0].Scope != "project" {
202 t.Fatalf("project hook should be diagnosed as active by default: %+v", r.Hooks.Entries)
203 }
204 }
205
206 func TestHasErrorSeverity(t *testing.T) {
207 if capdiag.HasErrorSeverity(capdiag.Report{}) {
208 t.Fatal("empty should be clean")
209 }
210 if !capdiag.HasErrorSeverity(capdiag.Report{Issues: []capdiag.Issue{{Severity: "error"}}}) {
211 t.Fatal("error severity not detected")
212 }
213 if capdiag.HasErrorSeverity(capdiag.Report{Issues: []capdiag.Issue{{Severity: "warning"}}}) {
214 t.Fatal("warning alone should not fail")
215 }
216 }
217
218 func TestLoadForRootReadOnlyDoesNotRewriteTier(t *testing.T) {
219 root := t.TempDir()
220 home := t.TempDir()
221 t.Setenv("HOME", home)
222 t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
223 userCfg := filepath.Join(home, ".reasonix", "config.toml")
224 if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
225 t.Fatal(err)
226 }
227 original := "[[plugins]]\nname = \"x\"\ncommand = \"echo\"\ntier = \"eager\"\n"
228 if err := os.WriteFile(userCfg, []byte(original), 0o644); err != nil {
229 t.Fatal(err)
230 }
231 _ = capdiag.Collect(capdiag.Options{
232 Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"),
233 })
234 raw, err := os.ReadFile(userCfg)
235 if err != nil {
236 t.Fatal(err)
237 }
238 if string(raw) != original {
239 t.Fatalf("static diagnostics rewrote config file:\n got %q\nwant %q", raw, original)
240 }
241 }
242
243 func TestUnknownHookEventIsReported(t *testing.T) {
244 root := t.TempDir()
245 home := t.TempDir()
246 t.Setenv("HOME", home)
247 t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
248 write(t, filepath.Join(root, ".reasonix", "settings.json"), `{
249 "hooks": {
250 "NotARealEvent": [{"command": "echo hi"}]
251 }
252 }`)
253 r := capdiag.Collect(capdiag.Options{
254 Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"),
255 })
256 found := false
257 for _, is := range r.Issues {
258 if is.Code == "hook.unknown_event" {
259 found = true
260 break
261 }
262 }
263 if !found {
264 t.Fatalf("expected hook.unknown_event, issues=%+v", r.Issues)
265 }
266 }
267
268 func TestCollectIgnoresMatchersOnNonToolHookEvents(t *testing.T) {
269 root := t.TempDir()
270 home := t.TempDir()
271 reasonixHome := filepath.Join(home, ".reasonix")
272 t.Setenv("HOME", home)
273 t.Setenv("REASONIX_HOME", reasonixHome)
274 write(t, filepath.Join(reasonixHome, "settings.json"), `{
275 "hooks": {
276 "Stop": [{"match": "(", "command": "echo done"}]
277 }
278 }`)
279
280 r := capdiag.Collect(capdiag.Options{
281 Root: root, HomeDir: home, ReasonixHomeDir: reasonixHome,
282 })
283 if len(r.Hooks.Entries) != 1 {
284 t.Fatalf("hook entries = %+v, want one Stop hook", r.Hooks.Entries)
285 }
286 for _, issue := range r.Issues {
287 if issue.Code == "hook.invalid_matcher" {
288 t.Fatalf("non-tool Stop matcher was reported invalid: %+v", issue)
289 }
290 }
291 }
292
293 func TestCollectRejectsNonRegularPluginContextFile(t *testing.T) {
294 root := t.TempDir()
295 home := t.TempDir()
296 reasonixHome := filepath.Join(home, ".reasonix")
297 t.Setenv("HOME", home)
298 t.Setenv("REASONIX_HOME", reasonixHome)
299
300 pluginRoot := filepath.Join(reasonixHome, "plugins", "demo")
301 write(t, filepath.Join(pluginRoot, pluginpkg.NativeManifest), `{
302 "name": "demo",
303 "hooks": {
304 "SessionStart": [{"contextFile": "CLAUDE.md"}]
305 }
306 }`)
307 if err := os.MkdirAll(filepath.Join(pluginRoot, "CLAUDE.md"), 0o755); err != nil {
308 t.Fatal(err)
309 }
310 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
311 Name: "demo", Root: "plugins/demo", ManifestKind: "reasonix", Enabled: true,
312 }); err != nil {
313 t.Fatal(err)
314 }
315
316 r := capdiag.Collect(capdiag.Options{
317 Root: root, HomeDir: home, ReasonixHomeDir: reasonixHome,
318 })
319 for _, issue := range r.Issues {
320 if issue.Code == "hook.missing_context_file" {
321 return
322 }
323 }
324 t.Fatalf("expected hook.missing_context_file for context directory, issues=%+v", r.Issues)
325 }
326
327 func TestPluginPackageCommandsAreReported(t *testing.T) {
328 root := t.TempDir()
329 home := t.TempDir()
330 reasonixHome := filepath.Join(home, ".reasonix")
331 t.Setenv("HOME", home)
332 t.Setenv("REASONIX_HOME", reasonixHome)
333
334 pluginRoot := filepath.Join(reasonixHome, "plugins", "demo")
335 write(t, filepath.Join(pluginRoot, pluginpkg.NativeManifest), `{"name":"demo","commands":["commands"]}`)
336 write(t, filepath.Join(pluginRoot, "commands", "ship.md"), "---\ndescription: ship it\n---\nShip $ARGUMENTS\n")
337 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
338 Name: "demo", Root: "plugins/demo", ManifestKind: "reasonix", Enabled: true,
339 }); err != nil {
340 t.Fatal(err)
341 }
342
343 r := capdiag.Collect(capdiag.Options{
344 Root: root, HomeDir: home, ReasonixHomeDir: reasonixHome,
345 })
346 if len(r.Plugins.Packages) != 1 {
347 t.Fatalf("plugin packages = %+v, want demo", r.Plugins.Packages)
348 }
349 pkg := r.Plugins.Packages[0]
350 if pkg.Commands != 1 {
351 t.Fatalf("plugin commands = %d, want 1", pkg.Commands)
352 }
353 if r.Commands.Winners != 1 {
354 t.Fatalf("command winners = %d, want plugin command", r.Commands.Winners)
355 }
356 if text := capdiag.RenderText(r); !strings.Contains(text, "commands=1") {
357 t.Fatalf("text report omitted plugin commands:\n%s", text)
358 }
359 }
360
361 func TestDisplayPathExternal(t *testing.T) {
362 // External absolute path should not include user components in JSON.
363 root := t.TempDir()
364 home := t.TempDir()
365 ext := filepath.Join(t.TempDir(), "secret-user-bin", "tool")
366 write(t, filepath.Join(root, "reasonix.toml"), `
367 [[plugins]]
368 name = "ext"
369 type = "stdio"
370 command = "`+filepath.ToSlash(ext)+`"
371 `)
372 // Create the binary so we don't also get command_not_found noise on path form.
373 write(t, ext, "#!/bin/sh\n")
374 if runtime.GOOS != "windows" {
375 _ = os.Chmod(ext, 0o755)
376 }
377 r := capdiag.Collect(capdiag.Options{
378 Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"),
379 })
380 raw, _ := json.Marshal(r)
381 if strings.Contains(string(raw), "secret-user-bin") {
382 // basename of command is "tool"; parent dir name must not appear.
383 // displayPath uses base only for external: <external>/tool
384 t.Fatalf("leaked external parent path: %s", raw)
385 }
386 }
387
388 func write(t *testing.T, path, body string) {
389 t.Helper()
390 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
391 t.Fatal(err)
392 }
393 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
394 t.Fatal(err)
395 }
396 }
397
397 lines GO