devrites 5.12.0 → 5.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/docs/architecture.md +2 -2
- package/docs/command-map.md +3 -1
- package/docs/engine/commands.md +21 -0
- package/docs/flow.md +2 -1
- package/docs/skills.md +13 -4
- package/engine/help.go +4 -1
- package/engine/help_test.go +2 -0
- package/engine/internal/install/binary.go +4 -0
- package/engine/internal/install/install_test.go +22 -0
- package/engine/internal/overhaul/admit/admit_test.go +170 -0
- package/engine/internal/overhaul/admit/run.go +416 -0
- package/engine/internal/overhaul/bench/run.go +248 -0
- package/engine/internal/overhaul/bench/run_test.go +139 -0
- package/engine/internal/overhaul/overhaul.go +64 -0
- package/engine/internal/overhaul/ovio/ovio.go +158 -0
- package/engine/internal/overhaul/records/check.go +834 -0
- package/engine/internal/overhaul/records/records_test.go +577 -0
- package/engine/internal/overhaul/records/run.go +392 -0
- package/engine/internal/overhaul/render/run.go +776 -0
- package/engine/internal/overhaul/render/run_test.go +293 -0
- package/engine/internal/overhaul/score/run.go +600 -0
- package/engine/internal/overhaul/score/run_test.go +405 -0
- package/engine/internal/overhaul/snapshot/run.go +563 -0
- package/engine/internal/overhaul/snapshot/run_test.go +360 -0
- package/engine/main.go +4 -0
- package/engine/root_routing_test.go +2 -1
- package/pack/.claude/skills/overhaul/SKILL.md +201 -0
- package/pack/.claude/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/.claude/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/.claude/skills/overhaul/references/orchestration.md +264 -0
- package/pack/.claude/skills/overhaul/references/records.md +137 -0
- package/pack/.claude/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/.claude/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/.claude/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/.claude/skills/overhaul/references/scoring.md +222 -0
- package/pack/.claude/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/.claude/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/.claude/skills/overhaul/roles/boundary.md +56 -0
- package/pack/.claude/skills/overhaul/roles/craft.md +77 -0
- package/pack/.claude/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/.claude/skills/overhaul/roles/implementer.md +59 -0
- package/pack/.claude/skills/overhaul/roles/recon.md +60 -0
- package/pack/.claude/skills/overhaul/roles/specialist.md +56 -0
- package/pack/.claude/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/claude/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/claude/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/claude/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/claude/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/claude/skills/overhaul/references/records.md +137 -0
- package/pack/generated/claude/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/claude/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/claude/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/claude/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/claude/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/claude/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/claude/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/claude/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/claude/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/claude/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/claude/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/claude/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/codex/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/codex/skills/overhaul/agents/openai.yaml +2 -0
- package/pack/generated/codex/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/codex/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/codex/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/codex/skills/overhaul/references/records.md +137 -0
- package/pack/generated/codex/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/codex/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/codex/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/codex/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/codex/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/codex/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/codex/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/codex/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/codex/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/codex/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/codex/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/codex/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/devin/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/devin/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/devin/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/devin/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/devin/skills/overhaul/references/records.md +137 -0
- package/pack/generated/devin/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/devin/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/devin/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/devin/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/devin/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/devin/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/devin/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/devin/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/devin/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/devin/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/devin/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/devin/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/omp/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/omp/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/omp/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/omp/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/omp/skills/overhaul/references/records.md +137 -0
- package/pack/generated/omp/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/omp/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/omp/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/omp/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/omp/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/omp/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/omp/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/omp/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/omp/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/omp/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/omp/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/omp/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/pi/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/pi/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/pi/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/pi/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/pi/skills/overhaul/references/records.md +137 -0
- package/pack/generated/pi/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/pi/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/pi/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/pi/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/pi/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/pi/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/pi/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/pi/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/pi/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/pi/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/pi/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/pi/skills/overhaul/roles/verifier.md +60 -0
- package/package.json +1 -1
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
package records
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"path"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"reflect"
|
|
11
|
+
"sort"
|
|
12
|
+
"strings"
|
|
13
|
+
|
|
14
|
+
"github.com/devrites/devrites/internal/overhaul/ovio"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func set(vals ...string) map[string]bool {
|
|
18
|
+
m := map[string]bool{}
|
|
19
|
+
for _, v := range vals {
|
|
20
|
+
m[v] = true
|
|
21
|
+
}
|
|
22
|
+
return m
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var enums = []struct {
|
|
26
|
+
field string
|
|
27
|
+
allowed map[string]bool
|
|
28
|
+
}{
|
|
29
|
+
{"mode", set("full", "pr", "branch")},
|
|
30
|
+
{"phase", set("PREFLIGHT", "AUDITING", "RECONCILING", "AWAITING_APPROVAL", "REMEDIATING", "VERIFYING",
|
|
31
|
+
"SCORING", "FINAL_CHALLENGE", "COMPLETE", "PAUSED", "STOPPED", "BLOCKED")},
|
|
32
|
+
{"readiness_verdict", set("NOT_ASSESSED", "NOT_ASSESSABLE", "NOT_READY", "MEETS_TARGETS",
|
|
33
|
+
"MEETS_TARGETS_WITH_APPROVED_DEGRADATION")},
|
|
34
|
+
{"execution_outcome", set("RUNNING", "AUDIT_COMPLETE", "AUDIT_INCOMPLETE", "AWAITING_APPROVAL",
|
|
35
|
+
"READY_FOR_USER_REVIEW", "COMPLETED_WITH_APPROVED_DEGRADATION", "STOPPED_BY_USER",
|
|
36
|
+
"BLOCKED_NEEDS_USER", "BLOCKED_ENVIRONMENT", "BLOCKED_PARALLEL_CAPABILITY",
|
|
37
|
+
"BUDGET_EXHAUSTED", "INCONCLUSIVE")},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var (
|
|
41
|
+
kinds = set("defect", "hardening", "maintainability", "opportunity")
|
|
42
|
+
origins = set("introduced", "exposed-existing", "pre-existing")
|
|
43
|
+
severities = set("critical", "high", "medium", "low", "informational")
|
|
44
|
+
moves = map[string]map[string]bool{ // allowed finding status transitions
|
|
45
|
+
"candidate": set("needs-validation", "confirmed", "rejected"),
|
|
46
|
+
"needs-validation": set("confirmed", "rejected"),
|
|
47
|
+
"confirmed": set("approved-for-fix", "deferred", "accepted-risk", "rejected"),
|
|
48
|
+
"approved-for-fix": set("in-progress", "deferred"),
|
|
49
|
+
"in-progress": set("fixed-unverified", "approved-for-fix"),
|
|
50
|
+
"fixed-unverified": set("verified", "in-progress"),
|
|
51
|
+
"verified": set("confirmed"),
|
|
52
|
+
"deferred": set("approved-for-fix", "confirmed"),
|
|
53
|
+
"accepted-risk": set("approved-for-fix", "confirmed"),
|
|
54
|
+
"rejected": set("candidate"),
|
|
55
|
+
}
|
|
56
|
+
severityOK = set("confirmed", "approved-for-fix", "in-progress", "fixed-unverified", "verified", "deferred", "accepted-risk")
|
|
57
|
+
attemptState = set("dispatched", "running", "returned", "admitted", "rejected", "cancelled", "timed-out", "unknown")
|
|
58
|
+
live = set("dispatched", "running", "unknown", "timed-out") // writer not proven stopped
|
|
59
|
+
writers = set("implementer")
|
|
60
|
+
taskOutcomes = set("completed", "blocked", "deferred", "failed", "not-started", "superseded")
|
|
61
|
+
required = []string{"run.json", "coverage.json", "findings.json", "evidence.json", "dispatch.json",
|
|
62
|
+
"approval.json", "cycles.json"}
|
|
63
|
+
channels = set("conversation", "host-structured-action")
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Repr renders a decoded JSON value the way Python's repr() does, so rule
|
|
67
|
+
// messages read the same as the original recipe's.
|
|
68
|
+
func Repr(v any) string {
|
|
69
|
+
switch x := v.(type) {
|
|
70
|
+
case nil:
|
|
71
|
+
return "None"
|
|
72
|
+
case bool:
|
|
73
|
+
if x {
|
|
74
|
+
return "True"
|
|
75
|
+
}
|
|
76
|
+
return "False"
|
|
77
|
+
case string:
|
|
78
|
+
return quote(x)
|
|
79
|
+
case json.Number:
|
|
80
|
+
return x.String()
|
|
81
|
+
case []string:
|
|
82
|
+
parts := make([]string, len(x))
|
|
83
|
+
for i, s := range x {
|
|
84
|
+
parts[i] = quote(s)
|
|
85
|
+
}
|
|
86
|
+
return "[" + strings.Join(parts, ", ") + "]"
|
|
87
|
+
case []any:
|
|
88
|
+
parts := make([]string, len(x))
|
|
89
|
+
for i, e := range x {
|
|
90
|
+
parts[i] = Repr(e)
|
|
91
|
+
}
|
|
92
|
+
return "[" + strings.Join(parts, ", ") + "]"
|
|
93
|
+
case map[string]any:
|
|
94
|
+
keys := make([]string, 0, len(x))
|
|
95
|
+
for k := range x {
|
|
96
|
+
keys = append(keys, k)
|
|
97
|
+
}
|
|
98
|
+
sort.Strings(keys)
|
|
99
|
+
parts := make([]string, len(keys))
|
|
100
|
+
for i, k := range keys {
|
|
101
|
+
parts[i] = quote(k) + ": " + Repr(x[k])
|
|
102
|
+
}
|
|
103
|
+
return "{" + strings.Join(parts, ", ") + "}"
|
|
104
|
+
}
|
|
105
|
+
return fmt.Sprint(v)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func quote(s string) string {
|
|
109
|
+
q := '\''
|
|
110
|
+
if strings.ContainsRune(s, '\'') && !strings.ContainsRune(s, '"') {
|
|
111
|
+
q = '"'
|
|
112
|
+
}
|
|
113
|
+
var b strings.Builder
|
|
114
|
+
b.WriteRune(q)
|
|
115
|
+
for _, r := range s {
|
|
116
|
+
switch {
|
|
117
|
+
case r == '\\' || r == q:
|
|
118
|
+
b.WriteRune('\\')
|
|
119
|
+
b.WriteRune(r)
|
|
120
|
+
case r == '\n':
|
|
121
|
+
b.WriteString(`\n`)
|
|
122
|
+
case r == '\r':
|
|
123
|
+
b.WriteString(`\r`)
|
|
124
|
+
case r == '\t':
|
|
125
|
+
b.WriteString(`\t`)
|
|
126
|
+
case r < 0x20 || r == 0x7f:
|
|
127
|
+
fmt.Fprintf(&b, `\x%02x`, r)
|
|
128
|
+
default:
|
|
129
|
+
b.WriteRune(r)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
b.WriteRune(q)
|
|
133
|
+
return b.String()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// PyStr is Python's str(): strings as-is, anything else as Repr.
|
|
137
|
+
func PyStr(v any) string {
|
|
138
|
+
if s, ok := v.(string); ok {
|
|
139
|
+
return s
|
|
140
|
+
}
|
|
141
|
+
return Repr(v)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// join mirrors os.path.join: an absolute part replaces everything before it.
|
|
145
|
+
func join(base string, parts ...string) string {
|
|
146
|
+
for _, p := range parts {
|
|
147
|
+
switch {
|
|
148
|
+
case strings.HasPrefix(p, "/"):
|
|
149
|
+
base = p
|
|
150
|
+
case base == "" || strings.HasSuffix(base, "/"):
|
|
151
|
+
base += p
|
|
152
|
+
default:
|
|
153
|
+
base += "/" + p
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return base
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func realpath(p string) string {
|
|
160
|
+
abs, err := filepath.Abs(p)
|
|
161
|
+
if err != nil {
|
|
162
|
+
abs = p
|
|
163
|
+
}
|
|
164
|
+
return ovio.Resolve(abs)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func inside(base, rel string) bool {
|
|
168
|
+
root := realpath(base)
|
|
169
|
+
return strings.HasPrefix(realpath(join(root, rel)), root+string(os.PathSeparator))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func isFile(p string) bool {
|
|
173
|
+
fi, err := os.Stat(p) // #nosec G703 -- stat only; path is inside the operator-selected run area
|
|
174
|
+
return err == nil && fi.Mode().IsRegular()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func isDir(p string) bool {
|
|
178
|
+
fi, err := os.Stat(p) // #nosec G703 -- stat only; path is inside the operator-selected run area
|
|
179
|
+
return err == nil && fi.IsDir()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func sha(p string) (string, error) { return ovio.SHA256File(p) }
|
|
183
|
+
|
|
184
|
+
// keyError mimics Python's KeyError text for a missing required field.
|
|
185
|
+
func keyError(k string) error { return fmt.Errorf("'%s'", k) }
|
|
186
|
+
|
|
187
|
+
// BadPath returns why p is not an exact repository-relative file path, or ""
|
|
188
|
+
// when it is one.
|
|
189
|
+
func BadPath(p any, repo string) string {
|
|
190
|
+
s, ok := p.(string)
|
|
191
|
+
if !ok || s == "" || strings.HasSuffix(s, "/") || path.IsAbs(s) || filepath.IsAbs(s) || strings.ContainsAny(s, "*?") {
|
|
192
|
+
return "not an exact relative file path"
|
|
193
|
+
}
|
|
194
|
+
if path.Clean(s) != s || strings.Split(s, "/")[0] == ".." {
|
|
195
|
+
return "not normalized or escapes the repository"
|
|
196
|
+
}
|
|
197
|
+
for _, part := range strings.Split(s, "/") {
|
|
198
|
+
if part == ".git" {
|
|
199
|
+
return "names a directory or git metadata"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if isDir(join(repo, s)) {
|
|
203
|
+
return "names a directory or git metadata"
|
|
204
|
+
}
|
|
205
|
+
if !inside(repo, s) {
|
|
206
|
+
return "resolves outside the repository (symlink or traversal)"
|
|
207
|
+
}
|
|
208
|
+
return ""
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// vset is a set of JSON values keyed by their Repr, keeping the value.
|
|
212
|
+
type vset map[string]any
|
|
213
|
+
|
|
214
|
+
func (s vset) add(v any) { s[Repr(v)] = v }
|
|
215
|
+
|
|
216
|
+
func (s vset) has(v any) bool {
|
|
217
|
+
_, ok := s[Repr(v)]
|
|
218
|
+
return ok
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func setOf(vals []any) vset {
|
|
222
|
+
s := vset{}
|
|
223
|
+
for _, v := range vals {
|
|
224
|
+
s.add(v)
|
|
225
|
+
}
|
|
226
|
+
return s
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// sorted mirrors Python's sorted(set) for the string members the records use.
|
|
230
|
+
func (s vset) sorted() []any {
|
|
231
|
+
out := make([]any, 0, len(s))
|
|
232
|
+
for _, v := range s {
|
|
233
|
+
out = append(out, v)
|
|
234
|
+
}
|
|
235
|
+
sort.Slice(out, func(i, j int) bool { return PyStr(out[i]) < PyStr(out[j]) })
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
func uniq(items []any, key func(map[string]any) string, label string, add func(string, ...any)) map[string]bool {
|
|
240
|
+
seen := map[string]bool{}
|
|
241
|
+
for _, it := range items {
|
|
242
|
+
k := key(ovio.Obj(it))
|
|
243
|
+
if seen[k] {
|
|
244
|
+
add("%s: duplicate id %s", label, k)
|
|
245
|
+
}
|
|
246
|
+
seen[k] = true
|
|
247
|
+
}
|
|
248
|
+
return seen
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func idKey(m map[string]any) string { return Repr(m["id"]) }
|
|
252
|
+
|
|
253
|
+
func str(m map[string]any, k string) string { return ovio.Str(m[k]) }
|
|
254
|
+
|
|
255
|
+
func eq(a, b any) bool { return reflect.DeepEqual(a, b) }
|
|
256
|
+
|
|
257
|
+
// prefixEq reports whether list starts with prefix (Python list[:len(p)] == p).
|
|
258
|
+
func prefixEq(list, prefix []any) bool {
|
|
259
|
+
if len(list) < len(prefix) {
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
for i := range prefix {
|
|
263
|
+
if !eq(list[i], prefix[i]) {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func decode(b []byte) (any, error) {
|
|
271
|
+
dec := json.NewDecoder(bytes.NewReader(b))
|
|
272
|
+
dec.UseNumber()
|
|
273
|
+
var v any
|
|
274
|
+
err := dec.Decode(&v)
|
|
275
|
+
return v, err
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
func closure(tasks map[string]map[string]any, picked []any) vset {
|
|
279
|
+
need, stack := vset{}, append([]any(nil), picked...)
|
|
280
|
+
for len(stack) > 0 {
|
|
281
|
+
t := stack[len(stack)-1]
|
|
282
|
+
stack = stack[:len(stack)-1]
|
|
283
|
+
for _, d := range ovio.List(tasks[Repr(t)]["deps"]) {
|
|
284
|
+
if !need.has(d) {
|
|
285
|
+
need.add(d)
|
|
286
|
+
stack = append(stack, d)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for _, p := range picked {
|
|
291
|
+
delete(need, Repr(p))
|
|
292
|
+
}
|
|
293
|
+
return need
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func cyclic(tasks map[string]map[string]any) bool {
|
|
297
|
+
state := map[string]int{}
|
|
298
|
+
var visit func(k string) bool
|
|
299
|
+
visit = func(k string) bool {
|
|
300
|
+
switch state[k] {
|
|
301
|
+
case 1:
|
|
302
|
+
return true
|
|
303
|
+
case 2:
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
state[k] = 1
|
|
307
|
+
hit := false
|
|
308
|
+
for _, d := range ovio.List(tasks[k]["deps"]) {
|
|
309
|
+
if _, ok := tasks[Repr(d)]; ok && visit(Repr(d)) {
|
|
310
|
+
hit = true
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
state[k] = 2
|
|
315
|
+
return hit
|
|
316
|
+
}
|
|
317
|
+
for k := range tasks {
|
|
318
|
+
if visit(k) {
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Check validates one generation directory (optionally against the previous
|
|
326
|
+
// one) and returns rule violations. An error means an unreadable input.
|
|
327
|
+
func Check(run, genDir, prevDir string) ([]string, error) {
|
|
328
|
+
var errs []string
|
|
329
|
+
add := func(f string, a ...any) { errs = append(errs, fmt.Sprintf(f, a...)) }
|
|
330
|
+
R := map[string]map[string]any{}
|
|
331
|
+
for _, name := range required {
|
|
332
|
+
p := join(genDir, name)
|
|
333
|
+
if !isFile(p) {
|
|
334
|
+
add("missing %s", name)
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
b, err := os.ReadFile(p) // #nosec G304 G703 -- fixed record name inside the generation directory
|
|
338
|
+
if err != nil {
|
|
339
|
+
return nil, err
|
|
340
|
+
}
|
|
341
|
+
v, err := decode(b)
|
|
342
|
+
if err == nil {
|
|
343
|
+
if m, ok := v.(map[string]any); ok {
|
|
344
|
+
R[name] = m
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
err = fmt.Errorf("expected a JSON object")
|
|
348
|
+
}
|
|
349
|
+
add("%s: invalid JSON (%v)", name, err)
|
|
350
|
+
}
|
|
351
|
+
if len(errs) > 0 {
|
|
352
|
+
return errs, nil
|
|
353
|
+
}
|
|
354
|
+
for _, name := range required {
|
|
355
|
+
want := "overhaul." + strings.TrimSuffix(name, ".json") + "/1"
|
|
356
|
+
if !eq(R[name]["schema"], want) {
|
|
357
|
+
add("%s: schema must be %s", name, want)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
rj := R["run.json"]
|
|
361
|
+
for _, e := range enums {
|
|
362
|
+
if !e.allowed[str(rj, e.field)] {
|
|
363
|
+
keys := make([]string, 0, len(e.allowed))
|
|
364
|
+
for k := range e.allowed {
|
|
365
|
+
keys = append(keys, k)
|
|
366
|
+
}
|
|
367
|
+
sort.Strings(keys)
|
|
368
|
+
add("run.json: %s=%s not in %s", e.field, Repr(rj[e.field]), Repr(keys))
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
repo := str(rj, "repo_root")
|
|
372
|
+
if !isDir(repo) {
|
|
373
|
+
add("run.json: repo_root is not a directory")
|
|
374
|
+
}
|
|
375
|
+
ev := ovio.List(R["evidence.json"]["items"])
|
|
376
|
+
evIDs := uniq(ev, idKey, "evidence", add)
|
|
377
|
+
for _, it := range ev {
|
|
378
|
+
e := ovio.Obj(it)
|
|
379
|
+
p := str(e, "path")
|
|
380
|
+
if p == "" || !inside(run, p) || !isFile(join(run, p)) {
|
|
381
|
+
add("evidence %s: path missing or outside run dir", PyStr(e["id"]))
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
d, err := sha(join(run, p))
|
|
385
|
+
if err != nil {
|
|
386
|
+
return nil, err
|
|
387
|
+
}
|
|
388
|
+
if !eq(d, e["sha256"]) {
|
|
389
|
+
add("evidence %s: sha256 missing or mismatched", PyStr(e["id"]))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
att := ovio.List(R["dispatch.json"]["attempts"])
|
|
393
|
+
vr := map[string]bool{}
|
|
394
|
+
for _, it := range att {
|
|
395
|
+
x := ovio.Obj(it)
|
|
396
|
+
if str(x, "role") == "verifier" && str(x, "status") == "admitted" {
|
|
397
|
+
if r, ok := x["receipt"].(string); ok {
|
|
398
|
+
vr[r] = true
|
|
399
|
+
}
|
|
400
|
+
vr[PyStr(x["task_id"])+"#"+PyStr(x["attempt_id"])] = true
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
fs := ovio.List(R["findings.json"]["findings"])
|
|
404
|
+
fIDs := uniq(fs, idKey, "finding", add)
|
|
405
|
+
var fps []any
|
|
406
|
+
for _, f := range fs {
|
|
407
|
+
if ovio.Truthy(ovio.Obj(f)["fingerprint"]) {
|
|
408
|
+
fps = append(fps, f)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
uniq(fps, func(m map[string]any) string { return Repr(m["fingerprint"]) }, "finding fingerprint", add)
|
|
412
|
+
for _, it := range fs {
|
|
413
|
+
f := ovio.Obj(it)
|
|
414
|
+
fid, hist, status := PyStr(f["id"]), ovio.List(f["history"]), str(f, "status")
|
|
415
|
+
if !kinds[str(f, "kind")] || !origins[str(f, "scope_origin")] {
|
|
416
|
+
add("finding %s: bad kind or scope_origin", fid)
|
|
417
|
+
}
|
|
418
|
+
if len(hist) == 0 || str(ovio.Obj(hist[0]), "status") != "candidate" || !eq(ovio.Obj(hist[len(hist)-1])["status"], f["status"]) {
|
|
419
|
+
add("finding %s: history must start at candidate and end at the status", fid)
|
|
420
|
+
}
|
|
421
|
+
for i := 1; i < len(hist); i++ {
|
|
422
|
+
a, b := ovio.Obj(hist[i-1]), ovio.Obj(hist[i])
|
|
423
|
+
if eq(b["status"], a["status"]) && ovio.Truthy(b["reason"]) {
|
|
424
|
+
continue // re-assessment entry (severity or kind change)
|
|
425
|
+
}
|
|
426
|
+
if !moves[str(a, "status")][str(b, "status")] {
|
|
427
|
+
add("finding %s: illegal transition %s -> %s", fid, PyStr(a["status"]), PyStr(b["status"]))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if f["severity"] != nil && (!severities[str(f, "severity")] || !severityOK[status]) {
|
|
431
|
+
add("finding %s: severity only for established impact", fid)
|
|
432
|
+
}
|
|
433
|
+
if set("confirmed", "verified", "rejected")[status] && !ovio.Truthy(f["evidence"]) {
|
|
434
|
+
add("finding %s: %s needs evidence", fid, status)
|
|
435
|
+
}
|
|
436
|
+
if vb, ok := f["verified_by"].(string); status == "verified" && !(ok && vr[vb]) {
|
|
437
|
+
add("finding %s: verified_by must name an admitted verifier attempt", fid)
|
|
438
|
+
}
|
|
439
|
+
if status == "rejected" && (len(hist) == 0 || !ovio.Truthy(ovio.Obj(hist[len(hist)-1])["reason"])) {
|
|
440
|
+
add("finding %s: rejection needs a reason", fid)
|
|
441
|
+
}
|
|
442
|
+
if status == "needs-validation" && !ovio.Truthy(f["blockers"]) {
|
|
443
|
+
add("finding %s: needs-validation must name the missing fact (blockers)", fid)
|
|
444
|
+
}
|
|
445
|
+
for _, l := range ovio.List(f["locations"]) {
|
|
446
|
+
loc := ovio.Obj(l)
|
|
447
|
+
lp := PyStr(loc["path"])
|
|
448
|
+
var bad bool
|
|
449
|
+
if ovio.Truthy(loc["external"]) {
|
|
450
|
+
bad = path.IsAbs(lp) || filepath.IsAbs(lp) || strings.HasPrefix(lp, "~")
|
|
451
|
+
} else {
|
|
452
|
+
bad = BadPath(lp, repo) != ""
|
|
453
|
+
}
|
|
454
|
+
if bad {
|
|
455
|
+
add("finding %s: location %s is not a repository-relative path or label", fid, Repr(lp))
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for _, ref := range ovio.List(f["evidence"]) {
|
|
459
|
+
if !evIDs[Repr(ref)] {
|
|
460
|
+
add("finding %s: unknown evidence %s", fid, PyStr(ref))
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for _, ref := range ovio.List(f["related"]) {
|
|
464
|
+
if !fIDs[Repr(ref)] {
|
|
465
|
+
add("finding %s: unknown related finding %s", fid, PyStr(ref))
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
var plan map[string]any
|
|
470
|
+
tasks := map[string]map[string]any{}
|
|
471
|
+
revs := ovio.Obj(rj["revisions"])
|
|
472
|
+
pr := ovio.Obj(revs["plan"])
|
|
473
|
+
hasPR := len(pr) > 0
|
|
474
|
+
if hasPR && !eq(pr["file"], "revisions/plan-r"+PyStr(pr["rev"])+".json") {
|
|
475
|
+
add("run.json: revisions.plan.file must be revisions/plan-r<rev>.json")
|
|
476
|
+
} else if hasPR {
|
|
477
|
+
var err error
|
|
478
|
+
if plan, err = ovio.LoadObject(join(run, str(pr, "file"))); err != nil {
|
|
479
|
+
return nil, err
|
|
480
|
+
}
|
|
481
|
+
if !eq(plan["rev"], pr["rev"]) {
|
|
482
|
+
add("plan revision number mismatch")
|
|
483
|
+
}
|
|
484
|
+
var controls vset
|
|
485
|
+
rub := ovio.Obj(plan["rubric"])
|
|
486
|
+
if rf := str(rub, "file"); rf == "" {
|
|
487
|
+
add("plan: rubric digest mismatch")
|
|
488
|
+
} else if d, err := sha(join(run, rf)); err != nil {
|
|
489
|
+
return nil, err
|
|
490
|
+
} else if !eq(d, rub["sha256"]) {
|
|
491
|
+
add("plan: rubric digest mismatch")
|
|
492
|
+
} else {
|
|
493
|
+
doc, err := ovio.LoadObject(join(run, rf))
|
|
494
|
+
if err != nil {
|
|
495
|
+
return nil, err
|
|
496
|
+
}
|
|
497
|
+
controls = vset{}
|
|
498
|
+
for _, c := range ovio.List(doc["controls"]) {
|
|
499
|
+
controls.add(ovio.Obj(c)["id"])
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for _, r := range ovio.List(plan["pinned"]) {
|
|
503
|
+
ref := ovio.Obj(r)
|
|
504
|
+
if _, ok := ref["file"]; !ok {
|
|
505
|
+
return nil, keyError("file")
|
|
506
|
+
}
|
|
507
|
+
d, err := sha(join(run, str(ref, "file")))
|
|
508
|
+
if err != nil {
|
|
509
|
+
return nil, err
|
|
510
|
+
}
|
|
511
|
+
if !eq(d, ref["sha256"]) {
|
|
512
|
+
add("plan: pinned %s digest mismatch", PyStr(ref["file"]))
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
tl := ovio.List(plan["tasks"])
|
|
516
|
+
uniq(tl, idKey, "task", add)
|
|
517
|
+
var order []any // first-insertion order, like a Python dict
|
|
518
|
+
for _, it := range tl {
|
|
519
|
+
t := ovio.Obj(it)
|
|
520
|
+
if _, ok := tasks[Repr(t["id"])]; !ok {
|
|
521
|
+
order = append(order, t["id"])
|
|
522
|
+
}
|
|
523
|
+
tasks[Repr(t["id"])] = t
|
|
524
|
+
}
|
|
525
|
+
for _, tid := range order {
|
|
526
|
+
t, ts := tasks[Repr(tid)], PyStr(tid)
|
|
527
|
+
for _, d := range ovio.List(t["deps"]) {
|
|
528
|
+
if _, ok := tasks[Repr(d)]; !ok {
|
|
529
|
+
add("task %s: unknown dependency %s", ts, PyStr(d))
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
for _, ref := range ovio.List(t["findings"]) {
|
|
533
|
+
if !fIDs[Repr(ref)] {
|
|
534
|
+
add("task %s: unknown finding %s", ts, PyStr(ref))
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if controls != nil {
|
|
538
|
+
for _, c := range ovio.List(t["controls"]) {
|
|
539
|
+
if !controls.has(c) {
|
|
540
|
+
add("task %s: unknown control ids", ts)
|
|
541
|
+
break
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for _, p := range ovio.List(t["allowed_paths"]) {
|
|
546
|
+
if why := BadPath(p, repo); why != "" {
|
|
547
|
+
add("task %s: allowed path %s %s", ts, Repr(p), why)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if !ovio.Truthy(t["oracle"]) || !ovio.Truthy(t["verifier_role"]) || eq(t["verifier_role"], t["writer_role"]) {
|
|
551
|
+
add("task %s: needs an oracle and a separate verifier", ts)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if cyclic(tasks) {
|
|
555
|
+
add("plan: task dependencies contain a cycle")
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
rr, scp := ovio.Obj(revs["rubric"]), join(genDir, "scorecard.json")
|
|
559
|
+
if isFile(scp) {
|
|
560
|
+
if rf := str(rr, "file"); !(rf != "" && inside(run, rf) && isFile(join(run, rf))) {
|
|
561
|
+
add("run.json: a scorecard needs revisions.rubric naming its rubric file")
|
|
562
|
+
} else if ovio.Truthy(rr["sha256"]) {
|
|
563
|
+
d, err := sha(join(run, rf))
|
|
564
|
+
if err != nil {
|
|
565
|
+
return nil, err
|
|
566
|
+
}
|
|
567
|
+
if !eq(d, rr["sha256"]) {
|
|
568
|
+
add("run.json: revisions.rubric digest mismatch")
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
sc, err := ovio.LoadObject(scp)
|
|
572
|
+
if err != nil {
|
|
573
|
+
return nil, err
|
|
574
|
+
}
|
|
575
|
+
if v := sc["readiness_verdict"]; v != nil && !eq(v, rj["readiness_verdict"]) {
|
|
576
|
+
add("run.json: readiness_verdict differs from scorecard.json")
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
ap := ovio.List(R["approval.json"]["events"])
|
|
580
|
+
uniq(ap, idKey, "approval", add)
|
|
581
|
+
stopAt := -1
|
|
582
|
+
for i, it := range ap {
|
|
583
|
+
if k := str(ovio.Obj(it), "kind"); k == "stop" || k == "revoke" {
|
|
584
|
+
stopAt = i
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
var active, approvals []map[string]any
|
|
588
|
+
granted := map[string]vset{}
|
|
589
|
+
for i, it := range ap {
|
|
590
|
+
a := ovio.Obj(it)
|
|
591
|
+
if str(a, "kind") != "approve" {
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
approvals = append(approvals, a)
|
|
595
|
+
granted[Repr(a["id"])] = setOf(ovio.List(a["tasks"]))
|
|
596
|
+
if str(a, "status") == "active" {
|
|
597
|
+
if i < stopAt {
|
|
598
|
+
add("approval %s: still active after a later stop/revoke", PyStr(a["id"]))
|
|
599
|
+
} else if i > stopAt {
|
|
600
|
+
active = append(active, a)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
anyWriter := false
|
|
605
|
+
for _, it := range att {
|
|
606
|
+
anyWriter = anyWriter || writers[str(ovio.Obj(it), "role")]
|
|
607
|
+
}
|
|
608
|
+
if len(active) > 1 {
|
|
609
|
+
add("approval: more than one active approval")
|
|
610
|
+
}
|
|
611
|
+
if !hasPR && (len(granted) > 0 || anyWriter) {
|
|
612
|
+
add("run.json: approvals and writers need revisions.plan")
|
|
613
|
+
}
|
|
614
|
+
for _, it := range ap {
|
|
615
|
+
a := ovio.Obj(it)
|
|
616
|
+
kind, aid := str(a, "kind"), PyStr(a["id"])
|
|
617
|
+
if (kind == "approve" || kind == "accept-degradation") && (!ovio.Truthy(a["quote"]) || !channels[str(a, "channel")]) {
|
|
618
|
+
add("approval %s: needs the user's verbatim decision and a trusted channel", aid)
|
|
619
|
+
}
|
|
620
|
+
if kind != "approve" {
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
pf := join(run, "revisions", "plan-r"+PyStr(a["plan_rev"])+".json")
|
|
624
|
+
ok := isFile(pf)
|
|
625
|
+
if ok {
|
|
626
|
+
d, err := sha(pf)
|
|
627
|
+
if err != nil {
|
|
628
|
+
return nil, err
|
|
629
|
+
}
|
|
630
|
+
ok = eq(d, a["plan_digest"])
|
|
631
|
+
}
|
|
632
|
+
if !ok {
|
|
633
|
+
add("approval %s: plan digest does not match plan revision %s", aid, PyStr(a["plan_rev"]))
|
|
634
|
+
}
|
|
635
|
+
if str(a, "status") == "active" && plan != nil {
|
|
636
|
+
if !eq(a["plan_rev"], plan["rev"]) {
|
|
637
|
+
add("approval %s: approves a stale plan revision", aid)
|
|
638
|
+
}
|
|
639
|
+
picked := ovio.List(a["tasks"])
|
|
640
|
+
missing, unknown := closure(tasks, picked), vset{}
|
|
641
|
+
for _, t := range picked {
|
|
642
|
+
if _, ok := tasks[Repr(t)]; !ok {
|
|
643
|
+
unknown.add(t)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if len(unknown) > 0 {
|
|
647
|
+
add("approval %s: unknown tasks %s", aid, Repr(unknown.sorted()))
|
|
648
|
+
}
|
|
649
|
+
if len(missing) > 0 {
|
|
650
|
+
add("approval %s: selected tasks miss prerequisites %s", aid, Repr(missing.sorted()))
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
assessmentOnly := ovio.Truthy(rj["assessment_only"])
|
|
655
|
+
if assessmentOnly && len(active) > 0 {
|
|
656
|
+
add("assessment-only run carries an active approval")
|
|
657
|
+
}
|
|
658
|
+
uniq(att, func(x map[string]any) string {
|
|
659
|
+
return "(" + Repr(x["task_id"]) + ", " + Repr(x["attempt_id"]) + ")"
|
|
660
|
+
}, "attempt", add)
|
|
661
|
+
approved := vset{}
|
|
662
|
+
if len(active) > 0 {
|
|
663
|
+
approved = setOf(ovio.List(active[0]["tasks"]))
|
|
664
|
+
}
|
|
665
|
+
liveWrites := map[string]string{}
|
|
666
|
+
var unsettled []any
|
|
667
|
+
for _, it := range att {
|
|
668
|
+
x := ovio.Obj(it)
|
|
669
|
+
xid, status := PyStr(x["task_id"])+"#"+PyStr(x["attempt_id"]), str(x, "status")
|
|
670
|
+
if !attemptState[status] {
|
|
671
|
+
add("attempt %s: bad status", xid)
|
|
672
|
+
}
|
|
673
|
+
if set("returned", "admitted", "rejected")[status] && !(str(x, "receipt") != "" && isFile(join(run, str(x, "receipt")))) {
|
|
674
|
+
add("attempt %s: receipt file missing", xid)
|
|
675
|
+
}
|
|
676
|
+
if set("dispatched", "running", "returned")[status] {
|
|
677
|
+
unsettled = append(unsettled, xid)
|
|
678
|
+
}
|
|
679
|
+
if !writers[str(x, "role")] {
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
if assessmentOnly {
|
|
683
|
+
add("attempt %s: writer dispatched in an assessment-only run", xid)
|
|
684
|
+
}
|
|
685
|
+
if (status == "dispatched" || status == "running") && !approved.has(x["task_id"]) {
|
|
686
|
+
add("attempt %s: writer task is not in the active approval", xid)
|
|
687
|
+
}
|
|
688
|
+
if !granted[Repr(x["authorized_by"])].has(x["task_id"]) {
|
|
689
|
+
add("attempt %s: authorized_by does not name an approval of this task", xid)
|
|
690
|
+
}
|
|
691
|
+
allowed := setOf(ovio.List(tasks[Repr(x["task_id"])]["allowed_paths"]))
|
|
692
|
+
extra := vset{}
|
|
693
|
+
for _, p := range ovio.List(x["changed_paths"]) {
|
|
694
|
+
if !allowed.has(p) {
|
|
695
|
+
extra.add(p)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if status == "admitted" && len(extra) > 0 {
|
|
699
|
+
add("attempt %s: admitted changes outside allowed paths %s", xid, Repr(extra.sorted()))
|
|
700
|
+
}
|
|
701
|
+
if live[status] {
|
|
702
|
+
for _, p := range ovio.List(x["write_paths"]) {
|
|
703
|
+
k := strings.ToLower(realpath(join(repo, ovio.Str(p))))
|
|
704
|
+
if prev, ok := liveWrites[k]; ok {
|
|
705
|
+
add("single writer: %s and %s both hold %s", prev, xid, PyStr(p))
|
|
706
|
+
}
|
|
707
|
+
liveWrites[k] = xid
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
outcome := str(rj, "execution_outcome")
|
|
712
|
+
if outcome != "RUNNING" && len(unsettled) > 0 {
|
|
713
|
+
add("attempts must be admitted, rejected or cancelled before the run leaves RUNNING: %s", Repr(unsettled[:min(5, len(unsettled))]))
|
|
714
|
+
}
|
|
715
|
+
if outcome != "RUNNING" && outcome != "AWAITING_APPROVAL" && len(approvals) > 0 && !assessmentOnly {
|
|
716
|
+
outs := ovio.Obj(rj["task_outcomes"])
|
|
717
|
+
var missing []any
|
|
718
|
+
for _, t := range ovio.List(approvals[len(approvals)-1]["tasks"]) {
|
|
719
|
+
var o any
|
|
720
|
+
if s, ok := t.(string); ok {
|
|
721
|
+
o = outs[s]
|
|
722
|
+
}
|
|
723
|
+
if !taskOutcomes[ovio.Str(o)] {
|
|
724
|
+
missing = append(missing, t)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if len(missing) > 0 {
|
|
728
|
+
add("approved tasks without exactly one recorded outcome: %s", Repr(missing))
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
cov := ovio.List(R["coverage.json"]["files"])
|
|
732
|
+
uniq(cov, func(c map[string]any) string { return Repr(c["path"]) }, "coverage", add)
|
|
733
|
+
for _, it := range cov {
|
|
734
|
+
c := ovio.Obj(it)
|
|
735
|
+
if !ovio.Truthy(c["eligible"]) && !ovio.Truthy(ovio.Get(c, "exclusion.reason")) {
|
|
736
|
+
add("coverage %s: ineligible without an exclusion reason", PyStr(c["path"]))
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if prevDir != "" {
|
|
740
|
+
prev := map[string]map[string]any{}
|
|
741
|
+
for _, n := range []string{"cycles.json", "findings.json", "approval.json", "run.json", "coverage.json"} {
|
|
742
|
+
m, err := ovio.LoadObject(join(prevDir, n))
|
|
743
|
+
if err != nil {
|
|
744
|
+
return nil, err
|
|
745
|
+
}
|
|
746
|
+
prev[n] = m
|
|
747
|
+
}
|
|
748
|
+
for _, k := range []string{"run_id", "repo_root", "mode", "assessment_only"} {
|
|
749
|
+
if !eq(prev["run.json"][k], rj[k]) {
|
|
750
|
+
add("run.json: %s changed; start a new run instead", k)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if !prefixEq(ovio.List(R["cycles.json"]["cycles"]), ovio.List(prev["cycles.json"]["cycles"])) {
|
|
754
|
+
add("cycles.json is append-only: an earlier cycle changed or disappeared")
|
|
755
|
+
}
|
|
756
|
+
cur := map[string]map[string]any{}
|
|
757
|
+
for _, it := range fs {
|
|
758
|
+
f := ovio.Obj(it)
|
|
759
|
+
cur[Repr(f["id"])] = f
|
|
760
|
+
}
|
|
761
|
+
for _, it := range ovio.List(prev["findings.json"]["findings"]) {
|
|
762
|
+
f := ovio.Obj(it)
|
|
763
|
+
g, ok := cur[Repr(f["id"])]
|
|
764
|
+
fh, isList := f["history"].([]any)
|
|
765
|
+
gh := ovio.List(g["history"])
|
|
766
|
+
if !ok || !isList || !prefixEq(gh, fh) {
|
|
767
|
+
add("finding %s: removed or history rewritten", PyStr(f["id"]))
|
|
768
|
+
} else if (!eq(g["severity"], f["severity"]) || !eq(g["kind"], f["kind"])) && len(gh) == len(fh) {
|
|
769
|
+
add("finding %s: severity or kind changed without a history entry", PyStr(f["id"]))
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
kept := vset{}
|
|
773
|
+
for _, c := range cov {
|
|
774
|
+
kept.add(ovio.Obj(c)["path"])
|
|
775
|
+
}
|
|
776
|
+
var gone []any
|
|
777
|
+
for _, c := range ovio.List(prev["coverage.json"]["files"]) {
|
|
778
|
+
if p := ovio.Obj(c)["path"]; !kept.has(p) {
|
|
779
|
+
gone = append(gone, p)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if len(gone) > 0 {
|
|
783
|
+
add("coverage denominator shrank: %s (exclude with a reason instead)", Repr(gone[:min(5, len(gone))]))
|
|
784
|
+
}
|
|
785
|
+
oldap := ovio.List(prev["approval.json"]["events"])
|
|
786
|
+
same := len(ap) >= len(oldap)
|
|
787
|
+
for i := 0; same && i < len(oldap); i++ {
|
|
788
|
+
same = eq(noStatus(ap[i]), noStatus(oldap[i]))
|
|
789
|
+
}
|
|
790
|
+
if !same {
|
|
791
|
+
add("approval.json events are append-only")
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
gen := strings.ReplaceAll(filepath.Base(strings.TrimRight(genDir, "/")), ".tmp", "")
|
|
795
|
+
planStamp := "none"
|
|
796
|
+
if hasPR {
|
|
797
|
+
if _, ok := pr["file"]; !ok {
|
|
798
|
+
return nil, keyError("file")
|
|
799
|
+
}
|
|
800
|
+
d, err := sha(join(run, str(pr, "file")))
|
|
801
|
+
if err != nil {
|
|
802
|
+
return nil, err
|
|
803
|
+
}
|
|
804
|
+
planStamp = "r" + PyStr(pr["rev"]) + ":" + d
|
|
805
|
+
}
|
|
806
|
+
stamp := fmt.Sprintf("overhaul-stamp run=%s generation=%s plan=%s", PyStr(rj["run_id"]), gen, planStamp)
|
|
807
|
+
vdir := join(genDir, "views")
|
|
808
|
+
if isDir(vdir) {
|
|
809
|
+
entries, err := os.ReadDir(vdir)
|
|
810
|
+
if err != nil {
|
|
811
|
+
return nil, err
|
|
812
|
+
}
|
|
813
|
+
for _, v := range entries {
|
|
814
|
+
b, err := os.ReadFile(join(vdir, v.Name())) // #nosec G703 -- view file listed from the generation's own views directory
|
|
815
|
+
if err != nil {
|
|
816
|
+
return nil, err
|
|
817
|
+
}
|
|
818
|
+
if !bytes.Contains(b, []byte(stamp)) {
|
|
819
|
+
add("view %s: stamp does not match this generation (%s)", v.Name(), stamp)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return errs, nil
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// noStatus is dict(a, status=None): the event with its mutable status blanked.
|
|
827
|
+
func noStatus(v any) map[string]any {
|
|
828
|
+
m := map[string]any{}
|
|
829
|
+
for k, x := range ovio.Obj(v) {
|
|
830
|
+
m[k] = x
|
|
831
|
+
}
|
|
832
|
+
m["status"] = nil
|
|
833
|
+
return m
|
|
834
|
+
}
|