返回 DeepSeek-Reasonix
resolver.go
根目录 / internal / instruction / resolver.go
1 package instruction
2
3 import (
4 "crypto/sha256"
5 "fmt"
6 "html"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11
12 fileencoding "reasonix/internal/fileutil/encoding"
13 )
14
15 type Scope string
16
17 const (
18 ScopeUser Scope = "user"
19 ScopeAncestor Scope = "ancestor"
20 ScopeProject Scope = "project"
21 ScopeLocal Scope = "local"
22 )
23
24 var DocumentNames = []string{"REASONIX.md", "AGENTS.md", "CLAUDE.md"}
25 var LocalDocumentNames = []string{"REASONIX.local.md", "AGENTS.local.md", "CLAUDE.local.md"}
26
27 const MaxImportDepth = 5
28
29 type Import struct {
30 Path string
31 SourcePath string
32 }
33
34 type Document struct {
35 Path string
36 Scope Scope
37 Directory string
38 Body string
39 Imports []Import
40 Depth int
41 Order int
42 }
43
44 type Diagnostic struct {
45 Code string
46 Path string
47 SourcePath string
48 Line int
49 Message string
50 }
51
52 type Resolution struct {
53 Documents []Document
54 Diagnostics []Diagnostic
55 }
56
57 type ResolveOptions struct {
58 WorkspaceRoot string
59 TargetDir string
60 UserDir string
61 }
62
63 type candidate struct {
64 doc Document
65 priority int
66 }
67
68 type importState struct {
69 active map[string]bool
70 expanded map[string]bool
71 }
72
73 func Resolve(opts ResolveOptions) Resolution {
74 target := absolutePath(opts.TargetDir)
75 if target == "" {
76 target = absolutePath(".")
77 }
78 root := absolutePath(opts.WorkspaceRoot)
79 if root == "" {
80 root = nearestGitRoot(target)
81 if root == "" {
82 root = target
83 }
84 }
85
86 var result Resolution
87 if !pathWithin(target, root) {
88 result.Diagnostics = append(result.Diagnostics, Diagnostic{
89 Code: "target_outside_workspace", Path: target,
90 Message: fmt.Sprintf("instruction target %q is outside workspace %q", target, root),
91 })
92 target = root
93 }
94
95 var candidates []candidate
96 appendDir := func(dir, boundary string, importBoundaries []string, names []string, scope Scope, depth, priority int) {
97 for _, name := range names {
98 path := filepath.Join(dir, name)
99 body, info, ok, code := readConfinedDocument(path, boundary, "document_symlink_escape")
100 if code != "" {
101 result.Diagnostics = append(result.Diagnostics, Diagnostic{
102 Code: code, Path: path,
103 Message: fmt.Sprintf("rejected instruction document %q outside boundary %q", path, boundary),
104 })
105 continue
106 }
107 if !ok {
108 continue
109 }
110 identity := physicalIdentity(path, info)
111 imports := []Import{}
112 state := importState{active: map[string]bool{identity: true}, expanded: map[string]bool{}}
113 body = resolveDocumentImports(body, path, importBoundaries, 0, state, &imports, &result.Diagnostics)
114 candidates = append(candidates, candidate{
115 doc: Document{Path: path, Scope: scope, Directory: dir, Body: body, Imports: imports, Depth: depth},
116 priority: priority,
117 })
118 }
119 }
120
121 if userDir := absolutePath(opts.UserDir); userDir != "" {
122 appendDir(userDir, userDir, userInstructionImportRoots(userDir), DocumentNames, ScopeUser, -1, 0)
123 }
124 chain := directoryChain(root, target)
125 for depth, dir := range chain {
126 scope := ScopeAncestor
127 if depth == 0 {
128 scope = ScopeProject
129 }
130 appendDir(dir, root, []string{root}, DocumentNames, scope, depth, 10+depth*2)
131 appendDir(dir, root, []string{root}, LocalDocumentNames, ScopeLocal, depth, 11+depth*2)
132 }
133
134 // Content hashes are exact after decoding, trimming, and deterministic
135 // import expansion. More specific directories replace broader duplicates;
136 // equal-priority convention files keep the first configured source.
137 winnerByBody := map[[sha256.Size]byte]int{}
138 for i, item := range candidates {
139 digest := sha256.Sum256([]byte(item.doc.Body))
140 if previous, ok := winnerByBody[digest]; !ok || item.priority > candidates[previous].priority {
141 winnerByBody[digest] = i
142 }
143 }
144 for i, item := range candidates {
145 digest := sha256.Sum256([]byte(item.doc.Body))
146 if winnerByBody[digest] != i {
147 continue
148 }
149 item.doc.Order = len(result.Documents)
150 result.Documents = append(result.Documents, item.doc)
151 }
152 return result
153 }
154
155 func readOpenedDocument(f *os.File) (string, os.FileInfo, bool) {
156 defer f.Close()
157 info, err := f.Stat()
158 if err != nil {
159 return "", nil, false
160 }
161 b, err := io.ReadAll(f)
162 if err != nil {
163 return "", nil, false
164 }
165 body := strings.TrimSpace(string(fileencoding.DecodeToUTF8(b)))
166 return body, info, body != ""
167 }
168
169 func readConfinedDocument(path, boundary, escapeCode string) (string, os.FileInfo, bool, string) {
170 boundary = realDirectory(boundary)
171 root, err := os.OpenRoot(boundary)
172 if err != nil {
173 return "", nil, false, ""
174 }
175 defer root.Close()
176
177 rel, err := filepath.Rel(boundary, absolutePath(path))
178 if err == nil && filepath.IsLocal(rel) {
179 if f, openErr := root.Open(rel); openErr == nil {
180 body, info, ok := readOpenedDocument(f)
181 return body, info, ok, ""
182 }
183 }
184
185 // Root.Open deliberately rejects absolute symlinks, including ones whose
186 // target remains inside the root. Resolve those for compatibility, then open
187 // the resolved relative path through the same root handle. The second open
188 // remains confined if any component changes after EvalSymlinks.
189 realPath, err := filepath.EvalSymlinks(path)
190 if err != nil {
191 return "", nil, false, ""
192 }
193 if !pathWithin(realPath, boundary) {
194 return "", nil, false, escapeCode
195 }
196 rel, err = filepath.Rel(boundary, realPath)
197 if err != nil || !filepath.IsLocal(rel) {
198 return "", nil, false, escapeCode
199 }
200 f, err := root.Open(rel)
201 if err != nil {
202 return "", nil, false, ""
203 }
204 body, info, ok := readOpenedDocument(f)
205 return body, info, ok, ""
206 }
207
208 func resolveDocumentImports(body, sourcePath string, boundaries []string, depth int, state importState, imports *[]Import, diagnostics *[]Diagnostic) string {
209 if depth >= MaxImportDepth {
210 return body
211 }
212 lines := strings.Split(body, "\n")
213 for i, line := range lines {
214 target, ok := parseImportTarget(line)
215 if !ok {
216 continue
217 }
218 resolved, boundary, code := confinedImportPath(target, filepath.Dir(sourcePath), boundaries)
219 if code != "" {
220 *diagnostics = append(*diagnostics, Diagnostic{
221 Code: code, Path: target, SourcePath: sourcePath, Line: i + 1,
222 Message: fmt.Sprintf("rejected instruction import %q from %q", target, sourcePath),
223 })
224 lines[i] = line + " <!-- rejected: " + code + " -->"
225 continue
226 }
227 b, info, ok, readCode := readConfinedDocument(resolved, boundary, "import_symlink_escape")
228 if readCode != "" {
229 *diagnostics = append(*diagnostics, Diagnostic{
230 Code: readCode, Path: resolved, SourcePath: sourcePath, Line: i + 1,
231 Message: fmt.Sprintf("rejected instruction import %q from %q", resolved, sourcePath),
232 })
233 lines[i] = line + " <!-- rejected: " + readCode + " -->"
234 continue
235 }
236 if !ok {
237 *diagnostics = append(*diagnostics, Diagnostic{
238 Code: "import_unreadable", Path: resolved, SourcePath: sourcePath, Line: i + 1,
239 Message: fmt.Sprintf("instruction import %q could not be read", resolved),
240 })
241 continue
242 }
243 identity := physicalIdentity(resolved, info)
244 if state.active[identity] {
245 *diagnostics = append(*diagnostics, Diagnostic{
246 Code: "import_cycle", Path: resolved, SourcePath: sourcePath, Line: i + 1,
247 Message: fmt.Sprintf("instruction import cycle from %q to %q", sourcePath, resolved),
248 })
249 lines[i] = line + " <!-- skipped: import cycle -->"
250 continue
251 }
252 if state.expanded[identity] {
253 lines[i] = line + " <!-- skipped: duplicate import -->"
254 continue
255 }
256 state.active[identity] = true
257 expanded := resolveDocumentImports(b, resolved, boundaries, depth+1, state, imports, diagnostics)
258 delete(state.active, identity)
259 state.expanded[identity] = true
260 *imports = append(*imports, Import{Path: resolved, SourcePath: sourcePath})
261 rel, err := filepath.Rel(boundary, resolved)
262 if err != nil {
263 rel = resolved
264 }
265 if len(boundaries) > 0 && absolutePath(boundary) != absolutePath(boundaries[0]) {
266 rel = filepath.Join(filepath.Base(boundary), rel)
267 }
268 label := html.EscapeString(filepath.ToSlash(rel))
269 lines[i] = fmt.Sprintf("<instruction-import path=\"%s\">\n%s\n</instruction-import>", label, expanded)
270 }
271 return strings.Join(lines, "\n")
272 }
273
274 func parseImportTarget(line string) (string, bool) {
275 t := strings.TrimSpace(line)
276 if !strings.HasPrefix(t, "@") || len(t) == 1 || strings.ContainsAny(t, " \t") {
277 return "", false
278 }
279 path := t[1:]
280 if !strings.ContainsAny(path, "/\\") && !strings.Contains(path, ".") {
281 return "", false
282 }
283 return path, true
284 }
285
286 func confinedImportPath(target, sourceDir string, boundaries []string) (string, string, string) {
287 pathTarget := target
288 if target == "~" || strings.HasPrefix(target, "~/") || strings.HasPrefix(target, `~\`) {
289 home, err := os.UserHomeDir()
290 if err != nil || strings.TrimSpace(home) == "" {
291 return "", "", "import_outside_source"
292 }
293 pathTarget = filepath.Join(home, filepath.FromSlash(strings.TrimLeft(target[1:], `/\`)))
294 } else if strings.HasPrefix(target, "~") {
295 return "", "", "import_outside_source"
296 }
297 path := filepath.Clean(filepath.FromSlash(pathTarget))
298 if !filepath.IsAbs(path) {
299 path = filepath.Clean(filepath.Join(sourceDir, path))
300 }
301 boundary := importBoundaryForPath(path, boundaries)
302 if boundary == "" {
303 return "", "", "import_outside_source"
304 }
305 realPath, err := filepath.EvalSymlinks(path)
306 if err == nil && !pathWithin(realPath, realDirectory(boundary)) {
307 return "", "", "import_symlink_escape"
308 }
309 return path, boundary, ""
310 }
311
312 func importBoundaryForPath(path string, boundaries []string) string {
313 for _, boundary := range boundaries {
314 if pathWithin(path, boundary) {
315 return absolutePath(boundary)
316 }
317 }
318 return ""
319 }
320
321 func userInstructionImportRoots(userDir string) []string {
322 roots := []string{absolutePath(userDir)}
323 if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
324 for _, name := range []string{".reasonix", ".agents", ".agent", ".claude"} {
325 roots = append(roots, absolutePath(filepath.Join(home, name)))
326 }
327 }
328 out := make([]string, 0, len(roots))
329 seen := map[string]bool{}
330 for _, root := range roots {
331 if root == "" || seen[root] {
332 continue
333 }
334 seen[root] = true
335 out = append(out, root)
336 }
337 return out
338 }
339
340 func directoryChain(root, target string) []string {
341 if !pathWithin(target, root) {
342 return []string{root}
343 }
344 rel, err := filepath.Rel(root, target)
345 if err != nil || rel == "." {
346 return []string{root}
347 }
348 chain := []string{root}
349 current := root
350 for _, part := range strings.Split(rel, string(filepath.Separator)) {
351 if part == "" || part == "." {
352 continue
353 }
354 current = filepath.Join(current, part)
355 chain = append(chain, current)
356 }
357 return chain
358 }
359
360 func pathWithin(path, root string) bool {
361 path = absolutePath(path)
362 root = absolutePath(root)
363 if path == "" || root == "" {
364 return false
365 }
366 rel, err := filepath.Rel(root, path)
367 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
368 }
369
370 func absolutePath(path string) string {
371 if strings.TrimSpace(path) == "" {
372 return ""
373 }
374 abs, err := filepath.Abs(path)
375 if err != nil {
376 return filepath.Clean(path)
377 }
378 return filepath.Clean(abs)
379 }
380
381 func realDirectory(path string) string {
382 real, err := filepath.EvalSymlinks(path)
383 if err == nil {
384 return real
385 }
386 return absolutePath(path)
387 }
388
389 func physicalIdentity(path string, info os.FileInfo) string {
390 if real, err := filepath.EvalSymlinks(path); err == nil {
391 return absolutePath(real)
392 }
393 if info != nil {
394 return absolutePath(path)
395 }
396 return ""
397 }
398
399 func nearestGitRoot(dir string) string {
400 dir = absolutePath(dir)
401 for dir != "" {
402 if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
403 return dir
404 }
405 parent := filepath.Dir(dir)
406 if parent == dir {
407 return ""
408 }
409 dir = parent
410 }
411 return ""
412 }
413
413 lines GO