| 1 | package plancontract |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // MaxSteps bounds a plan's total step count. A plan past it is malformed rather |
| 10 | // than merely long: rejecting it beats projecting a silently truncated list. |
| 11 | const MaxSteps = 50 |
| 12 | |
| 13 | // Plan is one revision of a proposed approach. RequiresApproval is the planner's |
| 14 | // request, never its decision — the host's route owns whether execution gates. |
| 15 | // ID and Revision carry json:"-" because they are host-assigned: a planner |
| 16 | // cannot claim an identity the host did not give it. |
| 17 | type Plan struct { |
| 18 | ID string `json:"-"` |
| 19 | Revision int `json:"-"` |
| 20 | Objective string `json:"objective"` |
| 21 | Assumptions []Assumption `json:"assumptions,omitempty"` |
| 22 | NonGoals []string `json:"non_goals,omitempty"` |
| 23 | Steps []Step `json:"steps"` |
| 24 | RequiresApproval bool `json:"requires_approval,omitempty"` |
| 25 | } |
| 26 | |
| 27 | // Assumption is an unverified premise the plan rests on. Confirm names the |
| 28 | // cheapest check that would settle it; empty leaves that to the executor. |
| 29 | type Assumption struct { |
| 30 | Text string `json:"text"` |
| 31 | Confirm string `json:"confirm,omitempty"` |
| 32 | } |
| 33 | |
| 34 | // Step is one unit of the plan. An empty ParentID makes it a phase, otherwise it |
| 35 | // is a sub-step of that phase. DependsOn orders siblings and is advisory: the |
| 36 | // todo projection is serial, so a dependency becomes order, not gating. |
| 37 | type Step struct { |
| 38 | ID string `json:"id,omitempty"` |
| 39 | ParentID string `json:"parent_id,omitempty"` |
| 40 | Title string `json:"title"` |
| 41 | DependsOn []string `json:"depends_on,omitempty"` |
| 42 | VerifiedFiles []string `json:"verified_files,omitempty"` // paths the planner read |
| 43 | CandidateFiles []string `json:"candidate_files,omitempty"` // paths the planner inferred |
| 44 | Acceptance []Criterion `json:"acceptance,omitempty"` |
| 45 | Verification []Verification `json:"verification,omitempty"` |
| 46 | Risks []string `json:"risks,omitempty"` |
| 47 | } |
| 48 | |
| 49 | // Criterion is one acceptance criterion. Regression marks must-keep-passing |
| 50 | // behavior; Optional marks a nice-to-have that never blocks completion. ID is |
| 51 | // host-assigned so it can key an evidence requirement downstream. |
| 52 | type Criterion struct { |
| 53 | ID string `json:"-"` |
| 54 | Text string `json:"text"` |
| 55 | Regression bool `json:"regression,omitempty"` |
| 56 | Optional bool `json:"optional,omitempty"` |
| 57 | } |
| 58 | |
| 59 | // Verification is a command-level check. An empty Command accepts any |
| 60 | // delivery-verification command; Expect describes what a pass looks like. |
| 61 | type Verification struct { |
| 62 | Command string `json:"command,omitempty"` |
| 63 | Expect string `json:"expect,omitempty"` |
| 64 | } |
| 65 | |
| 66 | // Normalize returns a canonical copy: trimmed text, dropped empty entries, |
| 67 | // assigned IDs, and repaired parent and dependency references. It never fails — |
| 68 | // what it cannot repair is left for Validate to reject. |
| 69 | func (p Plan) Normalize() Plan { |
| 70 | out := Plan{ |
| 71 | ID: strings.TrimSpace(p.ID), |
| 72 | Revision: max(p.Revision, 1), |
| 73 | Objective: strings.TrimSpace(p.Objective), |
| 74 | NonGoals: cleanStrings(p.NonGoals), |
| 75 | RequiresApproval: p.RequiresApproval, |
| 76 | } |
| 77 | for _, a := range p.Assumptions { |
| 78 | text := strings.TrimSpace(a.Text) |
| 79 | if text == "" { |
| 80 | continue |
| 81 | } |
| 82 | out.Assumptions = append(out.Assumptions, Assumption{Text: text, Confirm: strings.TrimSpace(a.Confirm)}) |
| 83 | } |
| 84 | out.Steps = normalizeSteps(p.Steps) |
| 85 | return out |
| 86 | } |
| 87 | |
| 88 | func normalizeSteps(steps []Step) []Step { |
| 89 | out := make([]Step, 0, len(steps)) |
| 90 | for _, s := range steps { |
| 91 | title := strings.TrimSpace(s.Title) |
| 92 | if title == "" { |
| 93 | continue |
| 94 | } |
| 95 | out = append(out, Step{ |
| 96 | ID: strings.TrimSpace(s.ID), |
| 97 | ParentID: strings.TrimSpace(s.ParentID), |
| 98 | Title: title, |
| 99 | DependsOn: cleanStrings(s.DependsOn), |
| 100 | VerifiedFiles: cleanStrings(s.VerifiedFiles), |
| 101 | CandidateFiles: cleanStrings(s.CandidateFiles), |
| 102 | Acceptance: cleanCriteria(s.Acceptance), |
| 103 | Verification: cleanVerifications(s.Verification), |
| 104 | Risks: cleanStrings(s.Risks), |
| 105 | }) |
| 106 | } |
| 107 | assignStepIDs(out) |
| 108 | assignCriterionIDs(out) |
| 109 | repairParents(out) |
| 110 | repairDependencies(out) |
| 111 | return out |
| 112 | } |
| 113 | |
| 114 | // assignStepIDs fills blank and duplicate IDs with "s<n>", leaving the first |
| 115 | // use of an ID with its submitted value so parent and dependency references the |
| 116 | // planner did write keep resolving. |
| 117 | func assignStepIDs(steps []Step) { |
| 118 | used := make(map[string]bool, len(steps)) |
| 119 | for i := range steps { |
| 120 | id := steps[i].ID |
| 121 | if id == "" || used[id] { |
| 122 | steps[i].ID = "" |
| 123 | continue |
| 124 | } |
| 125 | used[id] = true |
| 126 | } |
| 127 | next := 1 |
| 128 | for i := range steps { |
| 129 | if steps[i].ID != "" { |
| 130 | continue |
| 131 | } |
| 132 | for { |
| 133 | id := fmt.Sprintf("plan_step_%02d", next) |
| 134 | next++ |
| 135 | if !used[id] { |
| 136 | used[id] = true |
| 137 | steps[i].ID = id |
| 138 | break |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func assignCriterionIDs(steps []Step) { |
| 145 | used := make(map[string]bool) |
| 146 | for i := range steps { |
| 147 | for j := range steps[i].Acceptance { |
| 148 | id := steps[i].Acceptance[j].ID |
| 149 | if id == "" || used[id] { |
| 150 | steps[i].Acceptance[j].ID = "" |
| 151 | continue |
| 152 | } |
| 153 | used[id] = true |
| 154 | } |
| 155 | } |
| 156 | next := 1 |
| 157 | for i := range steps { |
| 158 | for j := range steps[i].Acceptance { |
| 159 | if steps[i].Acceptance[j].ID != "" { |
| 160 | continue |
| 161 | } |
| 162 | for { |
| 163 | id := fmt.Sprintf("c%d", next) |
| 164 | next++ |
| 165 | if !used[id] { |
| 166 | used[id] = true |
| 167 | steps[i].Acceptance[j].ID = id |
| 168 | break |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // repairParents rewrites every parent reference to the top-level phase the step |
| 176 | // actually belongs to, so a normalized plan is literally two levels deep and |
| 177 | // Ordered reads the field instead of re-deriving it. |
| 178 | func repairParents(steps []Step) { |
| 179 | for i, phase := range phaseIDs(steps) { |
| 180 | steps[i].ParentID = phase |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func repairDependencies(steps []Step) { |
| 185 | index := make(map[string]bool, len(steps)) |
| 186 | for _, s := range steps { |
| 187 | index[s.ID] = true |
| 188 | } |
| 189 | for i := range steps { |
| 190 | kept := steps[i].DependsOn[:0] |
| 191 | seen := make(map[string]bool, len(steps[i].DependsOn)) |
| 192 | for _, dep := range steps[i].DependsOn { |
| 193 | if dep == steps[i].ID || !index[dep] || seen[dep] { |
| 194 | continue |
| 195 | } |
| 196 | seen[dep] = true |
| 197 | kept = append(kept, dep) |
| 198 | } |
| 199 | if len(kept) == 0 { |
| 200 | kept = nil |
| 201 | } |
| 202 | steps[i].DependsOn = kept |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // Validate reports every defect a normalized plan can still carry, joined so a |
| 207 | // planner can fix them in one revision instead of one per round. |
| 208 | func (p Plan) Validate() error { |
| 209 | var errs []error |
| 210 | if strings.TrimSpace(p.Objective) == "" { |
| 211 | errs = append(errs, errors.New("plan has no objective")) |
| 212 | } |
| 213 | if len(p.Steps) == 0 { |
| 214 | errs = append(errs, errors.New("plan has no steps")) |
| 215 | } |
| 216 | if len(p.Steps) > MaxSteps { |
| 217 | errs = append(errs, fmt.Errorf("plan has %d steps; the limit is %d", len(p.Steps), MaxSteps)) |
| 218 | } |
| 219 | seen := make(map[string]bool, len(p.Steps)) |
| 220 | for _, s := range p.Steps { |
| 221 | if strings.TrimSpace(s.Title) == "" { |
| 222 | errs = append(errs, fmt.Errorf("step %q has no title", s.ID)) |
| 223 | } |
| 224 | id := strings.TrimSpace(s.ID) |
| 225 | if id == "" { |
| 226 | errs = append(errs, fmt.Errorf("step %q has no id", s.Title)) |
| 227 | continue |
| 228 | } |
| 229 | if seen[id] { |
| 230 | errs = append(errs, fmt.Errorf("step id %q is used more than once", id)) |
| 231 | } |
| 232 | seen[id] = true |
| 233 | } |
| 234 | for _, group := range siblingGroups(p.Steps) { |
| 235 | if _, cyclic := sortSiblings(group); cyclic { |
| 236 | errs = append(errs, fmt.Errorf("steps %s form a dependency cycle", strings.Join(stepIDs(group), ", "))) |
| 237 | } |
| 238 | } |
| 239 | return errors.Join(errs...) |
| 240 | } |
| 241 | |
| 242 | func stepIDs(steps []Step) []string { |
| 243 | out := make([]string, 0, len(steps)) |
| 244 | for _, s := range steps { |
| 245 | out = append(out, s.ID) |
| 246 | } |
| 247 | return out |
| 248 | } |
| 249 | |
| 250 | func cleanStrings(in []string) []string { |
| 251 | out := make([]string, 0, len(in)) |
| 252 | seen := make(map[string]bool, len(in)) |
| 253 | for _, s := range in { |
| 254 | s = strings.TrimSpace(s) |
| 255 | if s == "" || seen[s] { |
| 256 | continue |
| 257 | } |
| 258 | seen[s] = true |
| 259 | out = append(out, s) |
| 260 | } |
| 261 | if len(out) == 0 { |
| 262 | return nil |
| 263 | } |
| 264 | return out |
| 265 | } |
| 266 | |
| 267 | func cleanCriteria(in []Criterion) []Criterion { |
| 268 | out := make([]Criterion, 0, len(in)) |
| 269 | for _, c := range in { |
| 270 | text := strings.TrimSpace(c.Text) |
| 271 | if text == "" { |
| 272 | continue |
| 273 | } |
| 274 | out = append(out, Criterion{ |
| 275 | ID: strings.TrimSpace(c.ID), |
| 276 | Text: text, |
| 277 | Regression: c.Regression, |
| 278 | Optional: c.Optional, |
| 279 | }) |
| 280 | } |
| 281 | if len(out) == 0 { |
| 282 | return nil |
| 283 | } |
| 284 | return out |
| 285 | } |
| 286 | |
| 287 | func cleanVerifications(in []Verification) []Verification { |
| 288 | out := make([]Verification, 0, len(in)) |
| 289 | for _, v := range in { |
| 290 | command := strings.TrimSpace(v.Command) |
| 291 | expect := strings.TrimSpace(v.Expect) |
| 292 | if command == "" && expect == "" { |
| 293 | continue |
| 294 | } |
| 295 | out = append(out, Verification{Command: command, Expect: expect}) |
| 296 | } |
| 297 | if len(out) == 0 { |
| 298 | return nil |
| 299 | } |
| 300 | return out |
| 301 | } |
| 302 |