返回 DeepSeek-Reasonix
commandmatch.go
根目录 / internal / evidence / commandmatch.go
1 package evidence
2
3 import (
4 "path/filepath"
5 "strings"
6
7 "reasonix/internal/shellparse"
8 )
9
10 // CommandMatches reports whether a cited verification command is proven by a
11 // command that actually ran. Models paraphrase commands when citing them
12 // (dropping a `cd` prefix, changing quote style, omitting flags), so byte
13 // equality rejects real verifications; instead both sides are split into
14 // shell segments and each cited segment must be covered by some ran segment.
15 func CommandMatches(cited, ran string) bool {
16 citedSegs := commandSegments(cited)
17 if len(citedSegs) == 0 {
18 return false
19 }
20 ranSegs := commandSegments(ran)
21 for _, c := range citedSegs {
22 if !segmentCovered(c, ranSegs) {
23 return false
24 }
25 }
26 return true
27 }
28
29 func segmentCovered(cited string, ranSegs []string) bool {
30 for _, r := range ranSegs {
31 if segmentMatches(cited, r) {
32 return true
33 }
34 }
35 return false
36 }
37
38 // segmentMatches accepts normalized equality, or a token subset with the same
39 // head token (e.g. cited "ls x 2>&1" against ran "ls -la x 2>&1"). One-token
40 // citations only match exactly, so a bare "ls" can't claim an unrelated run.
41 func segmentMatches(cited, ran string) bool {
42 ct, rt := segmentTokens(cited), segmentTokens(ran)
43 if len(ct) == 0 || len(rt) == 0 {
44 return false
45 }
46 if strings.Join(ct, " ") == strings.Join(rt, " ") {
47 return true
48 }
49 if len(ct) < 2 || ct[0] != rt[0] {
50 return false
51 }
52 have := make(map[string]bool, len(rt))
53 for _, t := range rt {
54 have[t] = true
55 }
56 for _, t := range ct {
57 if !have[t] {
58 return false
59 }
60 }
61 return true
62 }
63
64 var segmentSeparators = []string{"&&", "||", ";", "|", "\n"}
65
66 func commandSegments(s string) []string {
67 if segs, _, ok := shellparse.SplitTopLevel(s); ok {
68 return segs
69 }
70 parts := []string{s}
71 for _, sep := range segmentSeparators {
72 var next []string
73 for _, p := range parts {
74 next = append(next, strings.Split(p, sep)...)
75 }
76 parts = next
77 }
78 var segs []string
79 for _, p := range parts {
80 p = strings.TrimSpace(p)
81 if p == "" || strings.HasPrefix(p, "#") {
82 continue
83 }
84 segs = append(segs, p)
85 }
86 return segs
87 }
88
89 func segmentTokens(s string) []string {
90 if fields, malformed := shellparse.StaticFields(s); malformed == "" {
91 return fields
92 }
93 fields := strings.Fields(s)
94 tokens := make([]string, 0, len(fields))
95 for _, f := range fields {
96 f = strings.ReplaceAll(f, `"`, "")
97 f = strings.ReplaceAll(f, "'", "")
98 if f != "" {
99 tokens = append(tokens, f)
100 }
101 }
102 return tokens
103 }
104
105 func (l *Ledger) HasSuccessfulCommand(command string) bool {
106 command = strings.TrimSpace(command)
107 if l == nil || command == "" {
108 return false
109 }
110 l.mu.Lock()
111 defer l.mu.Unlock()
112 for _, r := range l.receipts {
113 if r.Success && isShellToolName(r.ToolName) && CommandMatches(command, r.Command) {
114 return true
115 }
116 }
117 return false
118 }
119
120 // HasFailedCommand reports whether the cited command ran this turn but exited
121 // non-zero — so callers can distinguish "ran and failed" from "never ran".
122 func (l *Ledger) HasFailedCommand(command string) bool {
123 command = strings.TrimSpace(command)
124 if l == nil || command == "" {
125 return false
126 }
127 l.mu.Lock()
128 defer l.mu.Unlock()
129 for _, r := range l.receipts {
130 if !r.Success && isShellToolName(r.ToolName) && CommandMatches(command, r.Command) {
131 return true
132 }
133 }
134 return false
135 }
136
137 // SuccessfulCommands returns up to limit successful bash commands from this
138 // turn, most recent first, for self-correction hints in rejection errors.
139 func (l *Ledger) SuccessfulCommands(limit int) []string {
140 if l == nil || limit <= 0 {
141 return nil
142 }
143 l.mu.Lock()
144 defer l.mu.Unlock()
145 var out []string
146 for i := len(l.receipts) - 1; i >= 0 && len(out) < limit; i-- {
147 r := l.receipts[i]
148 if r.Success && isShellToolName(r.ToolName) && r.Command != "" {
149 out = append(out, r.Command)
150 }
151 }
152 return out
153 }
154
155 // HasSuccessfulBashMentioningPaths reports whether every path appears in some
156 // successful bash command this turn — files created or edited through shell
157 // redirection (`seq … > file`) leave no reader/writer receipt, so the command
158 // text naming the path is the receipt.
159 func (l *Ledger) HasSuccessfulBashMentioningPaths(paths []string) bool {
160 wanted := normalizePaths(paths)
161 if l == nil || len(wanted) == 0 {
162 return false
163 }
164 l.mu.Lock()
165 defer l.mu.Unlock()
166 for _, p := range wanted {
167 needle := strings.ToLower(filepath.ToSlash(p))
168 found := false
169 for _, r := range l.receipts {
170 if !r.Success || r.ToolName != "bash" {
171 continue
172 }
173 command := strings.ToLower(strings.ReplaceAll(r.Command, `\`, `/`))
174 if strings.Contains(command, needle) {
175 found = true
176 break
177 }
178 }
179 if !found {
180 return false
181 }
182 }
183 return true
184 }
185
186 func (l *Ledger) HasSuccessfulCommandAfter(command string, after int) bool {
187 command = strings.TrimSpace(command)
188 if l == nil || command == "" {
189 return false
190 }
191 start := max(after+1, 0)
192
193 l.mu.Lock()
194 defer l.mu.Unlock()
195 for i := start; i < len(l.receipts); i++ {
196 r := l.receipts[i]
197 if r.Success && isShellToolName(r.ToolName) && CommandMatches(command, r.Command) {
198 return true
199 }
200 }
201 return false
202 }
203
203 lines GO