devrites 5.12.0 → 5.13.0

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.
Files changed (136) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -3
  3. package/docs/architecture.md +2 -2
  4. package/docs/command-map.md +3 -1
  5. package/docs/engine/commands.md +21 -0
  6. package/docs/flow.md +2 -1
  7. package/docs/skills.md +13 -4
  8. package/engine/help.go +4 -1
  9. package/engine/help_test.go +2 -0
  10. package/engine/internal/overhaul/admit/admit_test.go +170 -0
  11. package/engine/internal/overhaul/admit/run.go +416 -0
  12. package/engine/internal/overhaul/bench/run.go +248 -0
  13. package/engine/internal/overhaul/bench/run_test.go +139 -0
  14. package/engine/internal/overhaul/overhaul.go +64 -0
  15. package/engine/internal/overhaul/ovio/ovio.go +158 -0
  16. package/engine/internal/overhaul/records/check.go +834 -0
  17. package/engine/internal/overhaul/records/records_test.go +577 -0
  18. package/engine/internal/overhaul/records/run.go +392 -0
  19. package/engine/internal/overhaul/render/run.go +776 -0
  20. package/engine/internal/overhaul/render/run_test.go +293 -0
  21. package/engine/internal/overhaul/score/run.go +600 -0
  22. package/engine/internal/overhaul/score/run_test.go +405 -0
  23. package/engine/internal/overhaul/snapshot/run.go +563 -0
  24. package/engine/internal/overhaul/snapshot/run_test.go +360 -0
  25. package/engine/main.go +4 -0
  26. package/engine/root_routing_test.go +2 -1
  27. package/pack/.claude/skills/overhaul/SKILL.md +201 -0
  28. package/pack/.claude/skills/overhaul/references/audit-domains.md +263 -0
  29. package/pack/.claude/skills/overhaul/references/lifecycle.md +308 -0
  30. package/pack/.claude/skills/overhaul/references/orchestration.md +264 -0
  31. package/pack/.claude/skills/overhaul/references/records.md +137 -0
  32. package/pack/.claude/skills/overhaul/references/repair-and-measurement.md +207 -0
  33. package/pack/.claude/skills/overhaul/references/review-and-report.md +137 -0
  34. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +191 -0
  35. package/pack/.claude/skills/overhaul/references/scoring.md +222 -0
  36. package/pack/.claude/skills/overhaul/references/stack-profiles.md +264 -0
  37. package/pack/.claude/skills/overhaul/roles/backend-engineer.md +61 -0
  38. package/pack/.claude/skills/overhaul/roles/boundary.md +56 -0
  39. package/pack/.claude/skills/overhaul/roles/craft.md +77 -0
  40. package/pack/.claude/skills/overhaul/roles/frontend-engineer.md +63 -0
  41. package/pack/.claude/skills/overhaul/roles/implementer.md +59 -0
  42. package/pack/.claude/skills/overhaul/roles/recon.md +60 -0
  43. package/pack/.claude/skills/overhaul/roles/specialist.md +56 -0
  44. package/pack/.claude/skills/overhaul/roles/verifier.md +60 -0
  45. package/pack/generated/claude/skills/overhaul/SKILL.md +201 -0
  46. package/pack/generated/claude/skills/overhaul/references/audit-domains.md +263 -0
  47. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +308 -0
  48. package/pack/generated/claude/skills/overhaul/references/orchestration.md +264 -0
  49. package/pack/generated/claude/skills/overhaul/references/records.md +137 -0
  50. package/pack/generated/claude/skills/overhaul/references/repair-and-measurement.md +207 -0
  51. package/pack/generated/claude/skills/overhaul/references/review-and-report.md +137 -0
  52. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +191 -0
  53. package/pack/generated/claude/skills/overhaul/references/scoring.md +222 -0
  54. package/pack/generated/claude/skills/overhaul/references/stack-profiles.md +264 -0
  55. package/pack/generated/claude/skills/overhaul/roles/backend-engineer.md +61 -0
  56. package/pack/generated/claude/skills/overhaul/roles/boundary.md +56 -0
  57. package/pack/generated/claude/skills/overhaul/roles/craft.md +77 -0
  58. package/pack/generated/claude/skills/overhaul/roles/frontend-engineer.md +63 -0
  59. package/pack/generated/claude/skills/overhaul/roles/implementer.md +59 -0
  60. package/pack/generated/claude/skills/overhaul/roles/recon.md +60 -0
  61. package/pack/generated/claude/skills/overhaul/roles/specialist.md +56 -0
  62. package/pack/generated/claude/skills/overhaul/roles/verifier.md +60 -0
  63. package/pack/generated/codex/skills/overhaul/SKILL.md +201 -0
  64. package/pack/generated/codex/skills/overhaul/agents/openai.yaml +2 -0
  65. package/pack/generated/codex/skills/overhaul/references/audit-domains.md +263 -0
  66. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +308 -0
  67. package/pack/generated/codex/skills/overhaul/references/orchestration.md +264 -0
  68. package/pack/generated/codex/skills/overhaul/references/records.md +137 -0
  69. package/pack/generated/codex/skills/overhaul/references/repair-and-measurement.md +207 -0
  70. package/pack/generated/codex/skills/overhaul/references/review-and-report.md +137 -0
  71. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +191 -0
  72. package/pack/generated/codex/skills/overhaul/references/scoring.md +222 -0
  73. package/pack/generated/codex/skills/overhaul/references/stack-profiles.md +264 -0
  74. package/pack/generated/codex/skills/overhaul/roles/backend-engineer.md +61 -0
  75. package/pack/generated/codex/skills/overhaul/roles/boundary.md +56 -0
  76. package/pack/generated/codex/skills/overhaul/roles/craft.md +77 -0
  77. package/pack/generated/codex/skills/overhaul/roles/frontend-engineer.md +63 -0
  78. package/pack/generated/codex/skills/overhaul/roles/implementer.md +59 -0
  79. package/pack/generated/codex/skills/overhaul/roles/recon.md +60 -0
  80. package/pack/generated/codex/skills/overhaul/roles/specialist.md +56 -0
  81. package/pack/generated/codex/skills/overhaul/roles/verifier.md +60 -0
  82. package/pack/generated/devin/skills/overhaul/SKILL.md +201 -0
  83. package/pack/generated/devin/skills/overhaul/references/audit-domains.md +263 -0
  84. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +308 -0
  85. package/pack/generated/devin/skills/overhaul/references/orchestration.md +264 -0
  86. package/pack/generated/devin/skills/overhaul/references/records.md +137 -0
  87. package/pack/generated/devin/skills/overhaul/references/repair-and-measurement.md +207 -0
  88. package/pack/generated/devin/skills/overhaul/references/review-and-report.md +137 -0
  89. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +191 -0
  90. package/pack/generated/devin/skills/overhaul/references/scoring.md +222 -0
  91. package/pack/generated/devin/skills/overhaul/references/stack-profiles.md +264 -0
  92. package/pack/generated/devin/skills/overhaul/roles/backend-engineer.md +61 -0
  93. package/pack/generated/devin/skills/overhaul/roles/boundary.md +56 -0
  94. package/pack/generated/devin/skills/overhaul/roles/craft.md +77 -0
  95. package/pack/generated/devin/skills/overhaul/roles/frontend-engineer.md +63 -0
  96. package/pack/generated/devin/skills/overhaul/roles/implementer.md +59 -0
  97. package/pack/generated/devin/skills/overhaul/roles/recon.md +60 -0
  98. package/pack/generated/devin/skills/overhaul/roles/specialist.md +56 -0
  99. package/pack/generated/devin/skills/overhaul/roles/verifier.md +60 -0
  100. package/pack/generated/omp/skills/overhaul/SKILL.md +201 -0
  101. package/pack/generated/omp/skills/overhaul/references/audit-domains.md +263 -0
  102. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +308 -0
  103. package/pack/generated/omp/skills/overhaul/references/orchestration.md +264 -0
  104. package/pack/generated/omp/skills/overhaul/references/records.md +137 -0
  105. package/pack/generated/omp/skills/overhaul/references/repair-and-measurement.md +207 -0
  106. package/pack/generated/omp/skills/overhaul/references/review-and-report.md +137 -0
  107. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +191 -0
  108. package/pack/generated/omp/skills/overhaul/references/scoring.md +222 -0
  109. package/pack/generated/omp/skills/overhaul/references/stack-profiles.md +264 -0
  110. package/pack/generated/omp/skills/overhaul/roles/backend-engineer.md +61 -0
  111. package/pack/generated/omp/skills/overhaul/roles/boundary.md +56 -0
  112. package/pack/generated/omp/skills/overhaul/roles/craft.md +77 -0
  113. package/pack/generated/omp/skills/overhaul/roles/frontend-engineer.md +63 -0
  114. package/pack/generated/omp/skills/overhaul/roles/implementer.md +59 -0
  115. package/pack/generated/omp/skills/overhaul/roles/recon.md +60 -0
  116. package/pack/generated/omp/skills/overhaul/roles/specialist.md +56 -0
  117. package/pack/generated/omp/skills/overhaul/roles/verifier.md +60 -0
  118. package/pack/generated/pi/skills/overhaul/SKILL.md +201 -0
  119. package/pack/generated/pi/skills/overhaul/references/audit-domains.md +263 -0
  120. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +308 -0
  121. package/pack/generated/pi/skills/overhaul/references/orchestration.md +264 -0
  122. package/pack/generated/pi/skills/overhaul/references/records.md +137 -0
  123. package/pack/generated/pi/skills/overhaul/references/repair-and-measurement.md +207 -0
  124. package/pack/generated/pi/skills/overhaul/references/review-and-report.md +137 -0
  125. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +191 -0
  126. package/pack/generated/pi/skills/overhaul/references/scoring.md +222 -0
  127. package/pack/generated/pi/skills/overhaul/references/stack-profiles.md +264 -0
  128. package/pack/generated/pi/skills/overhaul/roles/backend-engineer.md +61 -0
  129. package/pack/generated/pi/skills/overhaul/roles/boundary.md +56 -0
  130. package/pack/generated/pi/skills/overhaul/roles/craft.md +77 -0
  131. package/pack/generated/pi/skills/overhaul/roles/frontend-engineer.md +63 -0
  132. package/pack/generated/pi/skills/overhaul/roles/implementer.md +59 -0
  133. package/pack/generated/pi/skills/overhaul/roles/recon.md +60 -0
  134. package/pack/generated/pi/skills/overhaul/roles/specialist.md +56 -0
  135. package/pack/generated/pi/skills/overhaul/roles/verifier.md +60 -0
  136. package/package.json +1 -1
@@ -0,0 +1,600 @@
1
+ // Package score implements the /overhaul score tool (calculation
2
+ // overhaul-score/1): a deterministic readiness index over a frozen control
3
+ // catalog, its results and its gate records. All arithmetic is exact
4
+ // (math/big.Rat) and thresholds compare at full precision. Exit 2 means
5
+ // invalid input: nothing may be treated as scored.
6
+ package score
7
+
8
+ import (
9
+ "encoding/json"
10
+ "fmt"
11
+ "io"
12
+ "maps"
13
+ "math/big"
14
+ "os"
15
+ "slices"
16
+ "sort"
17
+
18
+ "github.com/devrites/devrites/internal/overhaul/ovio"
19
+ )
20
+
21
+ const calc = "overhaul-score/1"
22
+
23
+ const usage = `usage: overhaul score --rubric <r.json> --results <s.json> --gates <g.json> [--out <scorecard.json>]
24
+ overhaul score compare --rubric <r.json> --baseline <a.json> --candidate <b.json>`
25
+
26
+ var (
27
+ statuses = set("PASS", "FAIL", "UNKNOWN", "BLOCKED", "NOT_RUN", "DEFERRED", "ACCEPTED_RISK")
28
+ gateStatuses = set("PASS", "FAIL", "UNKNOWN", "NOT_APPLICABLE", "WAIVED_OPERATIONAL")
29
+ auditGates = []string{"G-MANDATORY", "G-NO-CRITICAL-HIGH", "G-NO-OPEN-SERIOUS-LEAD", "G-COVERAGE",
30
+ "G-CATALOG-ADEQUACY", "G-LANE-RECEIPTS", "G-CONCURRENCY", "G-FRESHNESS", "G-REPORT-CONSISTENCY"}
31
+ repairGates = []string{"G-APPROVAL", "G-TASKS-COMPLETE", "G-ORACLES", "G-NO-REGRESSION", "G-PERF-TARGETS",
32
+ "G-PRESERVATION", "G-FINGERPRINTS", "G-OWNERSHIP", "G-FINAL-CHALLENGE"}
33
+ mayBeNA = set("G-PERF-TARGETS", "G-LANE-RECEIPTS", "G-CONCURRENCY")
34
+ // domainOrder is the fixed domain set in its documented order, which also
35
+ // orders the per-domain threshold rows.
36
+ domainOrder = []string{"correctness", "security", "reliability", "performance", "tests", "architecture", "ux", "operations"}
37
+ defaultPolicy = [][2]string{{"global_min", "9.7"}, {"lane_min", "9.7"}, {"domain_min", "9.0"}, {"lane_domain_min", "9.0"}}
38
+ transitions = map[[2]string]string{
39
+ {"FAIL", "PASS"}: "fixed-or-newly-proven",
40
+ {"UNKNOWN", "PASS"}: "evidence-only",
41
+ {"PASS", "FAIL"}: "regression-or-new-failure",
42
+ {"UNKNOWN", "FAIL"}: "newly-demonstrated-failure",
43
+ {"PASS", "UNKNOWN"}: "evidence-invalidated",
44
+ }
45
+ )
46
+
47
+ func set(xs ...string) map[string]bool {
48
+ m := map[string]bool{}
49
+ for _, x := range xs {
50
+ m[x] = true
51
+ }
52
+ return m
53
+ }
54
+
55
+ // Run executes the tool with its arguments (tool name excluded).
56
+ func Run(args []string, stdout, stderr io.Writer) int {
57
+ out, err := run(args)
58
+ if err != nil {
59
+ fmt.Fprintf(stderr, "ERROR: %v\n", err)
60
+ return 2
61
+ }
62
+ fmt.Fprint(stdout, out)
63
+ return 0
64
+ }
65
+
66
+ func run(args []string) (string, error) {
67
+ if len(args) > 0 && args[0] == "compare" {
68
+ o, err := flags(args[1:], []string{"--rubric", "--baseline", "--candidate"}, nil)
69
+ if err != nil {
70
+ return "", err
71
+ }
72
+ docs, _, err := load(o["--rubric"], o["--baseline"], o["--candidate"])
73
+ if err != nil {
74
+ return "", err
75
+ }
76
+ res, err := compare(docs[0], docs[1], docs[2])
77
+ if err != nil {
78
+ return "", err
79
+ }
80
+ b, err := ovio.MarshalIndent(res)
81
+ return string(b), err
82
+ }
83
+ o, err := flags(args, []string{"--rubric", "--results", "--gates"}, []string{"--out"})
84
+ if err != nil {
85
+ return "", err
86
+ }
87
+ docs, digs, err := load(o["--rubric"], o["--results"], o["--gates"])
88
+ if err != nil {
89
+ return "", err
90
+ }
91
+ if d := docs[1]["rubric_digest"]; d != nil && d != any(digs[0]) {
92
+ return "", fmt.Errorf("results were produced for a different rubric digest")
93
+ }
94
+ out, err := score(docs[0], docs[1], docs[2])
95
+ if err != nil {
96
+ return "", err
97
+ }
98
+ out["inputs"] = map[string]any{"rubric_sha256": digs[0], "results_sha256": digs[1], "gates_sha256": digs[2]}
99
+ b, err := ovio.MarshalIndent(out)
100
+ if err != nil {
101
+ return "", err
102
+ }
103
+ path, ok := o["--out"]
104
+ if !ok {
105
+ return string(b), nil
106
+ }
107
+ if err := os.WriteFile(path, b, 0o644); err != nil {
108
+ return "", err
109
+ }
110
+ return fmt.Sprintf("%s -> %s\n", out["readiness_verdict"], path), nil
111
+ }
112
+
113
+ // flags parses "--name value" pairs; every required flag must be present and
114
+ // no other flag is accepted.
115
+ func flags(args, required, optional []string) (map[string]string, error) {
116
+ if len(args)%2 != 0 {
117
+ return nil, fmt.Errorf("every flag needs a value\n%s", usage)
118
+ }
119
+ o := map[string]string{}
120
+ for i := 0; i < len(args); i += 2 {
121
+ if !slices.Contains(required, args[i]) && !slices.Contains(optional, args[i]) {
122
+ return nil, fmt.Errorf("unknown argument %q\n%s", args[i], usage)
123
+ }
124
+ o[args[i]] = args[i+1]
125
+ }
126
+ for _, f := range required {
127
+ if _, ok := o[f]; !ok {
128
+ return nil, fmt.Errorf("missing %s\n%s", f, usage)
129
+ }
130
+ }
131
+ return o, nil
132
+ }
133
+
134
+ func load(paths ...string) ([]map[string]any, []string, error) {
135
+ var docs []map[string]any
136
+ var digs []string
137
+ for _, p := range paths {
138
+ m, err := ovio.LoadObject(p)
139
+ if err != nil {
140
+ return nil, nil, err
141
+ }
142
+ d, err := ovio.SHA256File(p)
143
+ if err != nil {
144
+ return nil, nil, err
145
+ }
146
+ docs, digs = append(docs, m), append(digs, d)
147
+ }
148
+ return docs, digs, nil
149
+ }
150
+
151
+ // rat parses a JSON number (or numeric string) exactly.
152
+ func rat(v any) (*big.Rat, bool) {
153
+ s, ok := v.(string)
154
+ if n, isNum := v.(json.Number); isNum {
155
+ s, ok = n.String(), true
156
+ }
157
+ if !ok {
158
+ return nil, false
159
+ }
160
+ return new(big.Rat).SetString(s)
161
+ }
162
+
163
+ func num(v any, what string) (*big.Rat, error) {
164
+ r, ok := rat(v)
165
+ if !ok {
166
+ return nil, fmt.Errorf("%s is not a finite number: %v", what, v)
167
+ }
168
+ if r.Sign() <= 0 {
169
+ return nil, fmt.Errorf("%s must be > 0: %v", what, v)
170
+ }
171
+ return r, nil
172
+ }
173
+
174
+ func needReason(v any, what string) error {
175
+ m := ovio.Obj(v)
176
+ if m == nil || !ovio.Truthy(m["reason"]) || !ovio.Truthy(m["evidence"]) {
177
+ return fmt.Errorf("%s needs reason and evidence", what)
178
+ }
179
+ return nil
180
+ }
181
+
182
+ type control struct {
183
+ id, domain string
184
+ lanes []string
185
+ weight *big.Rat
186
+ na, hard, indep bool
187
+ }
188
+
189
+ type rubricSpec struct {
190
+ policy map[string]*big.Rat
191
+ dw map[string]*big.Rat
192
+ excluded map[string]bool
193
+ lanes []string
194
+ laneExcl map[[2]string]bool
195
+ controls []control
196
+ }
197
+
198
+ func checkRubric(r map[string]any) (*rubricSpec, error) {
199
+ if r["schema"] != "overhaul.rubric/1" {
200
+ return nil, fmt.Errorf("rubric schema must be overhaul.rubric/1")
201
+ }
202
+ errPolicy := fmt.Errorf("policy may only raise the default thresholds (global_min 9.7, lane_min 9.7, domain_min 9.0, lane_domain_min 9.0) and has no other keys")
203
+ pol := ovio.Obj(r["policy"])
204
+ if pol == nil && ovio.Truthy(r["policy"]) {
205
+ return nil, errPolicy
206
+ }
207
+ sp := &rubricSpec{policy: map[string]*big.Rat{}, dw: map[string]*big.Rat{}, excluded: map[string]bool{},
208
+ laneExcl: map[[2]string]bool{}}
209
+ for _, kd := range defaultPolicy {
210
+ def, _ := new(big.Rat).SetString(kd[1])
211
+ val := def
212
+ if v, ok := pol[kd[0]]; ok {
213
+ if val, ok = rat(v); !ok {
214
+ return nil, fmt.Errorf("policy %s is not a finite number: %v", kd[0], v)
215
+ }
216
+ }
217
+ if val.Cmp(def) < 0 {
218
+ return nil, errPolicy
219
+ }
220
+ sp.policy[kd[0]] = val
221
+ }
222
+ for k := range pol {
223
+ if sp.policy[k] == nil {
224
+ return nil, errPolicy
225
+ }
226
+ }
227
+ doms := ovio.Obj(r["domains"])
228
+ for _, d := range slices.Sorted(maps.Keys(doms)) {
229
+ w, err := num(doms[d], "domain weight "+d)
230
+ if err != nil {
231
+ return nil, err
232
+ }
233
+ sp.dw[d] = w
234
+ }
235
+ for _, e := range ovio.List(r["domain_exclusions"]) {
236
+ d, ok := ovio.Obj(e)["domain"].(string)
237
+ if err := needReason(e, "domain exclusion "+d); err != nil {
238
+ return nil, err
239
+ }
240
+ if !ok {
241
+ return nil, fmt.Errorf("domain exclusion needs a domain")
242
+ }
243
+ sp.excluded[d] = true
244
+ }
245
+ known := set(domainOrder...)
246
+ for d := range sp.dw {
247
+ if !known[d] {
248
+ return nil, fmt.Errorf("every domain in %v needs a weight or a recorded exclusion; no others", domainOrder)
249
+ }
250
+ }
251
+ for _, d := range domainOrder {
252
+ if sp.dw[d] == nil && !sp.excluded[d] {
253
+ return nil, fmt.Errorf("every domain in %v needs a weight or a recorded exclusion; no others", domainOrder)
254
+ }
255
+ }
256
+ laneSet := map[string]bool{}
257
+ for _, l := range ovio.List(r["lanes"]) {
258
+ s, ok := l.(string)
259
+ if !ok || laneSet[s] {
260
+ return nil, fmt.Errorf("duplicate or non-string lane name: %v", l)
261
+ }
262
+ laneSet[s] = true
263
+ sp.lanes = append(sp.lanes, s)
264
+ }
265
+ for _, e := range ovio.List(r["lane_domain_exclusions"]) {
266
+ l, okL := ovio.Obj(e)["lane"].(string)
267
+ d, okD := ovio.Obj(e)["domain"].(string)
268
+ if err := needReason(e, fmt.Sprintf("lane-domain exclusion %s/%s", l, d)); err != nil {
269
+ return nil, err
270
+ }
271
+ if !okL || !okD {
272
+ return nil, fmt.Errorf("lane-domain exclusion needs a lane and a domain")
273
+ }
274
+ sp.laneExcl[[2]string{l, d}] = true
275
+ }
276
+ seen := map[string]bool{}
277
+ for _, cv := range ovio.List(r["controls"]) {
278
+ c := ovio.Obj(cv)
279
+ id, _ := c["id"].(string)
280
+ if id == "" || seen[id] {
281
+ return nil, fmt.Errorf("missing or duplicate control id: %q", id)
282
+ }
283
+ seen[id] = true
284
+ dom, _ := c["domain"].(string)
285
+ if sp.dw[dom] == nil {
286
+ return nil, fmt.Errorf("%s: unknown domain %v", id, c["domain"])
287
+ }
288
+ var lanes []string
289
+ var bad []any
290
+ for _, l := range ovio.List(c["lanes"]) {
291
+ if s, ok := l.(string); ok && laneSet[s] {
292
+ lanes = append(lanes, s)
293
+ } else {
294
+ bad = append(bad, l)
295
+ }
296
+ }
297
+ if len(bad) > 0 {
298
+ return nil, fmt.Errorf("%s: unknown lanes %v", id, bad)
299
+ }
300
+ na := c["na"]
301
+ if na != nil {
302
+ if err := needReason(na, id+" N/A"); err != nil {
303
+ return nil, err
304
+ }
305
+ if ovio.Obj(na)["approved"] != true {
306
+ return nil, fmt.Errorf("%s: N/A needs an approved rationale", id)
307
+ }
308
+ }
309
+ w, err := num(c["weight"], id+" weight")
310
+ if err != nil {
311
+ return nil, err
312
+ }
313
+ sp.controls = append(sp.controls, control{id: id, domain: dom, lanes: lanes, weight: w, na: na != nil,
314
+ hard: ovio.Truthy(c["hard_gate"]), indep: ovio.Truthy(c["independent_verification"])})
315
+ }
316
+ return sp, nil
317
+ }
318
+
319
+ // credit returns the control's effective status. Binary credit: only PASS
320
+ // earns; PASS without evidence or required verification counts as UNKNOWN.
321
+ func credit(c control, res map[string]any, notes *[]any) (string, error) {
322
+ st := "UNKNOWN"
323
+ if v, ok := res["status"]; ok {
324
+ s, isStr := v.(string)
325
+ if !isStr || !statuses[s] {
326
+ return "", fmt.Errorf("%s: unknown status %v", c.id, v)
327
+ }
328
+ st = s
329
+ }
330
+ if st == "PASS" && !ovio.Truthy(res["evidence"]) {
331
+ *notes = append(*notes, map[string]any{"control": c.id, "from": "PASS", "to": "UNKNOWN", "reason": "no evidence"})
332
+ st = "UNKNOWN"
333
+ }
334
+ if st == "PASS" && c.indep && !ovio.Truthy(res["verified_by"]) {
335
+ *notes = append(*notes, map[string]any{"control": c.id, "from": "PASS", "to": "UNKNOWN", "reason": "no independent verification"})
336
+ st = "UNKNOWN"
337
+ }
338
+ return st, nil
339
+ }
340
+
341
+ // agg is 10 * SUM(w*p) / SUM(w) over the kept controls; nil on a zero denominator.
342
+ func agg(live []control, pass map[string]bool, keep func(control) bool) *big.Rat {
343
+ den, sum := new(big.Rat), new(big.Rat)
344
+ for _, c := range live {
345
+ if keep(c) {
346
+ den.Add(den, c.weight)
347
+ if pass[c.id] {
348
+ sum.Add(sum, c.weight)
349
+ }
350
+ }
351
+ }
352
+ if den.Sign() == 0 {
353
+ return nil
354
+ }
355
+ sum.Mul(sum, big.NewRat(10, 1))
356
+ return sum.Quo(sum, den)
357
+ }
358
+
359
+ // weighted is SUM(W_d * v_d) / SUM(W_d); nil when ds is empty or any v_d is nil.
360
+ func weighted(ds []string, w, v map[string]*big.Rat) (q, den *big.Rat) {
361
+ den, sum := new(big.Rat), new(big.Rat)
362
+ ok := len(ds) > 0
363
+ for _, d := range ds {
364
+ den.Add(den, w[d])
365
+ if v[d] == nil {
366
+ ok = false
367
+ continue
368
+ }
369
+ sum.Add(sum, new(big.Rat).Mul(w[d], v[d]))
370
+ }
371
+ if !ok {
372
+ return nil, den
373
+ }
374
+ return sum.Quo(sum, den), den
375
+ }
376
+
377
+ func show(x *big.Rat) any {
378
+ if x == nil {
379
+ return nil
380
+ }
381
+ return map[string]any{"exact": x.Num().String() + "/" + x.Denom().String(), "decimal": x.FloatString(12)}
382
+ }
383
+
384
+ func score(rubric, results, gates map[string]any) (map[string]any, error) {
385
+ sp, err := checkRubric(rubric)
386
+ if err != nil {
387
+ return nil, err
388
+ }
389
+ if results["schema"] != "overhaul.results/1" {
390
+ return nil, fmt.Errorf("results schema must be overhaul.results/1")
391
+ }
392
+ rres := ovio.Obj(results["results"])
393
+ if rres == nil && ovio.Truthy(results["results"]) {
394
+ return nil, fmt.Errorf("results.results must be an object")
395
+ }
396
+ ids := map[string]bool{}
397
+ for _, c := range sp.controls {
398
+ ids[c.id] = true
399
+ }
400
+ var unknown []string
401
+ for k := range rres {
402
+ if !ids[k] {
403
+ unknown = append(unknown, k)
404
+ }
405
+ }
406
+ if len(unknown) > 0 {
407
+ sort.Strings(unknown)
408
+ return nil, fmt.Errorf("results for controls not in rubric: %v", unknown)
409
+ }
410
+
411
+ notes := []any{}
412
+ status := map[string]string{}
413
+ pass := map[string]bool{}
414
+ var live []control
415
+ passes := 0
416
+ for _, c := range sp.controls {
417
+ if c.na {
418
+ continue
419
+ }
420
+ rv, present := rres[c.id]
421
+ if present && ovio.Obj(rv) == nil {
422
+ return nil, fmt.Errorf("%s: result must be an object", c.id)
423
+ }
424
+ st, err := credit(c, ovio.Obj(rv), &notes)
425
+ if err != nil {
426
+ return nil, err
427
+ }
428
+ status[c.id], pass[c.id] = st, st == "PASS"
429
+ if st == "PASS" {
430
+ passes++
431
+ }
432
+ live = append(live, c)
433
+ }
434
+
435
+ var domains []string
436
+ for _, d := range domainOrder {
437
+ if sp.dw[d] != nil && !sp.excluded[d] {
438
+ domains = append(domains, d)
439
+ }
440
+ }
441
+ dom, domShow := map[string]*big.Rat{}, map[string]any{}
442
+ for _, d := range domains {
443
+ dom[d] = agg(live, pass, func(c control) bool { return c.domain == d })
444
+ domShow[d] = show(dom[d])
445
+ }
446
+ q, _ := weighted(domains, sp.dw, dom)
447
+
448
+ type check struct {
449
+ scope string
450
+ v, least *big.Rat
451
+ }
452
+ checks := []check{{"global", q, sp.policy["global_min"]}}
453
+ for _, d := range domains {
454
+ checks = append(checks, check{"domain " + d, dom[d], sp.policy["domain_min"]})
455
+ }
456
+ laneOut := map[string]any{}
457
+ appearances := 0
458
+ for _, c := range live {
459
+ appearances += len(c.lanes)
460
+ }
461
+ for _, l := range sp.lanes {
462
+ var lds []string
463
+ for _, d := range domains {
464
+ if !sp.laneExcl[[2]string{l, d}] {
465
+ lds = append(lds, d)
466
+ }
467
+ }
468
+ ld, ldShow := map[string]*big.Rat{}, map[string]any{}
469
+ for _, d := range lds {
470
+ ld[d] = agg(live, pass, func(c control) bool { return c.domain == d && slices.Contains(c.lanes, l) })
471
+ ldShow[d] = show(ld[d])
472
+ }
473
+ ql, den := weighted(lds, sp.dw, ld)
474
+ laneOut[l] = map[string]any{"Q": show(ql), "domains": ldShow, "weight_denominator": den.RatString()}
475
+ checks = append(checks, check{"lane " + l, ql, sp.policy["lane_min"]})
476
+ for _, d := range lds {
477
+ checks = append(checks, check{"lane " + l + " / " + d, ld[d], sp.policy["lane_domain_min"]})
478
+ }
479
+ }
480
+
481
+ rows := []any{}
482
+ assessable, allPass := true, true
483
+ for _, ck := range checks {
484
+ res := "PASS"
485
+ switch {
486
+ case ck.v == nil:
487
+ res, assessable = "NOT_ASSESSABLE", false
488
+ case ck.v.Cmp(ck.least) < 0:
489
+ res, allPass = "FAIL", false
490
+ }
491
+ rows = append(rows, map[string]any{"scope": ck.scope, "value": show(ck.v), "min": ck.least.RatString(), "result": res})
492
+ }
493
+ mandatory := []string{}
494
+ for _, c := range live {
495
+ if c.hard && !pass[c.id] {
496
+ mandatory = append(mandatory, c.id)
497
+ }
498
+ }
499
+
500
+ mode := gates["mode"]
501
+ if mode != "audit" && mode != "remediation" {
502
+ return nil, fmt.Errorf("gates.mode must be audit or remediation")
503
+ }
504
+ gateOut := map[string]string{"G-THRESHOLDS": "FAIL", "G-MANDATORY": "PASS"}
505
+ if !assessable {
506
+ gateOut["G-THRESHOLDS"] = "UNKNOWN"
507
+ } else if allPass {
508
+ gateOut["G-THRESHOLDS"] = "PASS"
509
+ }
510
+ if len(mandatory) > 0 {
511
+ gateOut["G-MANDATORY"] = "FAIL"
512
+ }
513
+ names := auditGates
514
+ if mode == "remediation" {
515
+ names = append(slices.Clone(auditGates), repairGates...)
516
+ }
517
+ given := ovio.Obj(gates["gates"])
518
+ for _, g := range names {
519
+ if gateOut[g] != "" {
520
+ continue
521
+ }
522
+ e := ovio.Obj(given[g])
523
+ if e == nil && ovio.Truthy(given[g]) {
524
+ return nil, fmt.Errorf("%s: gate record must be an object", g)
525
+ }
526
+ st := "UNKNOWN"
527
+ if v, ok := e["status"]; ok {
528
+ s, isStr := v.(string)
529
+ if !isStr || !gateStatuses[s] {
530
+ return nil, fmt.Errorf("%s: unknown gate status %v", g, v)
531
+ }
532
+ st = s
533
+ }
534
+ if st == "PASS" && !ovio.Truthy(e["evidence"]) {
535
+ st = "UNKNOWN"
536
+ notes = append(notes, map[string]any{"gate": g, "from": "PASS", "to": "UNKNOWN", "reason": "no evidence"})
537
+ }
538
+ if st == "NOT_APPLICABLE" && (!mayBeNA[g] || !ovio.Truthy(e["reason"])) {
539
+ return nil, fmt.Errorf("%s cannot be NOT_APPLICABLE without an allowed reason", g)
540
+ }
541
+ if st == "WAIVED_OPERATIONAL" && (g != "G-CONCURRENCY" || !ovio.Truthy(e["approval"])) {
542
+ return nil, fmt.Errorf("only G-CONCURRENCY may be WAIVED_OPERATIONAL, with an approval reference")
543
+ }
544
+ gateOut[g] = st
545
+ }
546
+ open := []string{}
547
+ for _, g := range slices.Sorted(maps.Keys(gateOut)) {
548
+ if s := gateOut[g]; s != "PASS" && s != "NOT_APPLICABLE" {
549
+ open = append(open, g)
550
+ }
551
+ }
552
+ verdict := "NOT_READY"
553
+ switch {
554
+ case !assessable:
555
+ verdict = "NOT_ASSESSABLE"
556
+ case len(open) == 0:
557
+ verdict = "MEETS_TARGETS"
558
+ case len(open) == 1 && open[0] == "G-CONCURRENCY" && gateOut["G-CONCURRENCY"] == "WAIVED_OPERATIONAL":
559
+ verdict = "MEETS_TARGETS_WITH_APPROVED_DEGRADATION"
560
+ }
561
+ excluded := slices.AppendSeq([]string{}, maps.Keys(sp.excluded))
562
+ sort.Strings(excluded)
563
+
564
+ return map[string]any{
565
+ "schema": "overhaul.scorecard/1", "calculation": calc, "subject": results["subject"],
566
+ "fingerprint": results["fingerprint"], "rubric_rev": rubric["rev"],
567
+ "global": map[string]any{"Q": show(q), "domains": domShow, "excluded_domains": excluded},
568
+ "lanes": laneOut, "thresholds": rows, "gates": gateOut,
569
+ "mandatory_failures": mandatory, "downgrades": notes, "status_by_control": status,
570
+ "counts": map[string]any{"controls": len(sp.controls), "not_applicable": len(sp.controls) - len(live),
571
+ "pass": passes, "lane_appearances": appearances},
572
+ "readiness_verdict": verdict, "open_gates": open,
573
+ }, nil
574
+ }
575
+
576
+ // compare scores baseline and candidate on one rubric and labels each
577
+ // per-control status transition.
578
+ func compare(rubric, a, b map[string]any) (map[string]any, error) {
579
+ sa, err := score(rubric, a, map[string]any{"mode": "audit"})
580
+ if err != nil {
581
+ return nil, fmt.Errorf("baseline: %w", err)
582
+ }
583
+ sb, err := score(rubric, b, map[string]any{"mode": "audit"})
584
+ if err != nil {
585
+ return nil, fmt.Errorf("candidate: %w", err)
586
+ }
587
+ before, after := sa["status_by_control"].(map[string]string), sb["status_by_control"].(map[string]string)
588
+ rows := []any{}
589
+ for _, id := range slices.Sorted(maps.Keys(before)) {
590
+ if before[id] != after[id] {
591
+ kind := transitions[[2]string{before[id], after[id]}]
592
+ if kind == "" {
593
+ kind = "status-change"
594
+ }
595
+ rows = append(rows, map[string]any{"control": id, "from": before[id], "to": after[id], "kind": kind})
596
+ }
597
+ }
598
+ return map[string]any{"baseline_Q": sa["global"].(map[string]any)["Q"],
599
+ "candidate_Q": sb["global"].(map[string]any)["Q"], "transitions": rows}, nil
600
+ }