返回 DeepSeek-Reasonix
writescope.go
根目录 / internal / evidence / writescope.go
1 package evidence
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 )
8
9 // WriteScope locates actual writes relative to workspace and scratch roots.
10 type WriteScope uint8
11
12 const (
13 WriteScopeWorkspace WriteScope = iota
14 WriteScopeScratch
15 WriteScopeOutside
16 )
17
18 func (s WriteScope) String() string {
19 switch s {
20 case WriteScopeScratch:
21 return "scratch"
22 case WriteScopeOutside:
23 return "outside"
24 default:
25 return "workspace"
26 }
27 }
28
29 // ClassifyWriteScope reports whether path is inside the workspace, a scratch
30 // root (session temp or OS temp), or somewhere else. Relative paths without a
31 // workspace root stay workspace so existing receipts keep their meaning.
32 func ClassifyWriteScope(path, workspaceRoot string, scratchRoots []string) WriteScope {
33 path = strings.TrimSpace(path)
34 if path == "" {
35 return WriteScopeWorkspace
36 }
37 abs := scopeAbs(path, workspaceRoot)
38 if workspaceRoot != "" && pathInside(workspaceRoot, abs) {
39 return WriteScopeWorkspace
40 }
41 if !filepath.IsAbs(path) && strings.TrimSpace(workspaceRoot) == "" {
42 return WriteScopeWorkspace
43 }
44 roots := scratchRootList(scratchRoots)
45 for _, root := range roots {
46 if pathInside(root, abs) {
47 return resolvedScratchScope(abs, workspaceRoot, roots)
48 }
49 }
50 if filepath.IsAbs(abs) {
51 return WriteScopeOutside
52 }
53 return WriteScopeWorkspace
54 }
55
56 // IsDeliveryMutation keeps every successful persistent mutation except writes
57 // proven to be scratch-only. Outside approved roots remain delivery work: they
58 // are durable user-visible changes even though they are not project files.
59 func IsDeliveryMutation(r Receipt, workspaceRoot string, scratchRoots []string) bool {
60 if !r.Success || !(r.Mutation || r.Write) || r.DeliveryScope == WriteScopeScratch {
61 return false
62 }
63 if len(r.Paths) == 0 {
64 return true
65 }
66 for _, path := range r.Paths {
67 if ClassifyWriteScope(path, workspaceRoot, scratchRoots) != WriteScopeScratch {
68 return true
69 }
70 }
71 return false
72 }
73
74 // resolvedScratchScope prevents a lexical temp path from hiding a symlink
75 // back into the workspace (or another persistent location). Scratch roots are
76 // resolved too, so the normal /tmp -> /private/tmp alias on macOS stays scratch.
77 func resolvedScratchScope(path, workspaceRoot string, scratchRoots []string) WriteScope {
78 resolved, err := resolveScopePath(path)
79 if err != nil {
80 return WriteScopeOutside
81 }
82 if workspaceRoot != "" {
83 if root, rootErr := resolveScopePath(workspaceRoot); rootErr == nil && pathInside(root, resolved) {
84 return WriteScopeWorkspace
85 }
86 }
87 for _, root := range scratchRoots {
88 resolvedRoot, rootErr := resolveScopePath(root)
89 if rootErr == nil && pathInside(resolvedRoot, resolved) {
90 return WriteScopeScratch
91 }
92 }
93 return WriteScopeOutside
94 }
95
96 // resolveScopePath resolves the deepest existing ancestor and appends the
97 // missing tail. Writers commonly create new scratch files, so EvalSymlinks on
98 // the complete path alone is insufficient.
99 func resolveScopePath(path string) (string, error) {
100 abs, err := filepath.Abs(path)
101 if err != nil {
102 return "", err
103 }
104 abs = filepath.Clean(abs)
105 tail := ""
106 cur := abs
107 for {
108 if real, evalErr := filepath.EvalSymlinks(cur); evalErr == nil {
109 return filepath.Join(real, tail), nil
110 }
111 parent := filepath.Dir(cur)
112 if parent == cur {
113 return abs, nil
114 }
115 tail = filepath.Join(filepath.Base(cur), tail)
116 cur = parent
117 }
118 }
119
120 // DefaultScratchRoots includes the OS temp directory and Unix public aliases.
121 // A supplied workspace root always wins for checkouts located under temp.
122 func DefaultScratchRoots() []string {
123 roots := []string{os.TempDir()}
124 if filepath.Separator == '/' {
125 roots = append(roots, "/tmp", "/private/tmp")
126 }
127 return uniqueCleanRoots(roots)
128 }
129
130 func scratchRootList(extra []string) []string {
131 return uniqueCleanRoots(append(DefaultScratchRoots(), extra...))
132 }
133
134 func uniqueCleanRoots(roots []string) []string {
135 seen := make(map[string]bool, len(roots))
136 var out []string
137 for _, root := range roots {
138 root = strings.TrimSpace(root)
139 if root == "" {
140 continue
141 }
142 cleaned := filepath.Clean(root)
143 key := strings.ToLower(cleaned)
144 if seen[key] {
145 continue
146 }
147 seen[key] = true
148 out = append(out, cleaned)
149 }
150 return out
151 }
152
153 func scopeAbs(path, workspaceRoot string) string {
154 if filepath.IsAbs(path) {
155 return filepath.Clean(path)
156 }
157 if strings.TrimSpace(workspaceRoot) == "" {
158 return filepath.Clean(path)
159 }
160 return filepath.Clean(filepath.Join(workspaceRoot, path))
161 }
162
163 func pathInside(root, target string) bool {
164 root = filepath.Clean(strings.TrimSpace(root))
165 target = filepath.Clean(strings.TrimSpace(target))
166 if root == "" || target == "" {
167 return false
168 }
169 if !strings.EqualFold(filepath.VolumeName(root), filepath.VolumeName(target)) {
170 return false
171 }
172 rel, err := filepath.Rel(root, target)
173 if err != nil {
174 return false
175 }
176 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
177 }
178
178 lines GO