| 1 | package plancontract |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // Diff is what one revision changed. Steps pair by id, which is why identity is |
| 10 | // host-assigned and never regenerated: a diff that pairs by position or title |
| 11 | // cannot tell a step that moved from a step that was replaced. |
| 12 | type Diff struct { |
| 13 | FromRevision int |
| 14 | ToRevision int |
| 15 | Objective *TextChange |
| 16 | Added []Step |
| 17 | Removed []Step |
| 18 | Changed []StepChange |
| 19 | Preserved []Step |
| 20 | } |
| 21 | |
| 22 | // TextChange is a plan-level field that moved. |
| 23 | type TextChange struct{ Before, After string } |
| 24 | |
| 25 | // StepChange names which parts of a step moved, because "the title was reworded" |
| 26 | // and "the acceptance criteria were rewritten" carry very different risk. |
| 27 | type StepChange struct { |
| 28 | Before Step |
| 29 | After Step |
| 30 | Fields []string |
| 31 | } |
| 32 | |
| 33 | // Compare pairs the two revisions by step id. It is a pure function so the host |
| 34 | // can render it, gate on it, and record it from the same result. |
| 35 | func Compare(before, after Plan) Diff { |
| 36 | before, after = before.Normalize(), after.Normalize() |
| 37 | d := Diff{FromRevision: before.Revision, ToRevision: after.Revision} |
| 38 | if before.Objective != after.Objective { |
| 39 | d.Objective = &TextChange{Before: before.Objective, After: after.Objective} |
| 40 | } |
| 41 | prev := make(map[string]Step, len(before.Steps)) |
| 42 | for _, step := range before.Steps { |
| 43 | prev[step.ID] = step |
| 44 | } |
| 45 | for _, step := range after.Ordered() { |
| 46 | old, existed := prev[step.ID] |
| 47 | delete(prev, step.ID) |
| 48 | if !existed { |
| 49 | d.Added = append(d.Added, step) |
| 50 | continue |
| 51 | } |
| 52 | if fields := changedFields(old, step); len(fields) > 0 { |
| 53 | d.Changed = append(d.Changed, StepChange{Before: old, After: step, Fields: fields}) |
| 54 | continue |
| 55 | } |
| 56 | d.Preserved = append(d.Preserved, step) |
| 57 | } |
| 58 | for _, step := range before.Ordered() { |
| 59 | if _, gone := prev[step.ID]; gone { |
| 60 | d.Removed = append(d.Removed, step) |
| 61 | } |
| 62 | } |
| 63 | return d |
| 64 | } |
| 65 | |
| 66 | func changedFields(before, after Step) []string { |
| 67 | var fields []string |
| 68 | add := func(name string, same bool) { |
| 69 | if !same { |
| 70 | fields = append(fields, name) |
| 71 | } |
| 72 | } |
| 73 | add("title", before.Title == after.Title) |
| 74 | add("phase", before.ParentID == after.ParentID) |
| 75 | add("depends_on", slices.Equal(before.DependsOn, after.DependsOn)) |
| 76 | add("verified_files", slices.Equal(before.VerifiedFiles, after.VerifiedFiles)) |
| 77 | add("candidate_files", slices.Equal(before.CandidateFiles, after.CandidateFiles)) |
| 78 | add("acceptance", slices.Equal(before.Acceptance, after.Acceptance)) |
| 79 | add("verification", slices.Equal(before.Verification, after.Verification)) |
| 80 | add("risks", slices.Equal(before.Risks, after.Risks)) |
| 81 | return fields |
| 82 | } |
| 83 | |
| 84 | // NeedsApproval reports whether the revision expands what the user agreed to. |
| 85 | // Narrowing, reordering, retitling, and adding evidence never do: re-asking for |
| 86 | // those trains the user to approve without reading, which costs more than the |
| 87 | // gate saves. |
| 88 | func (d Diff) NeedsApproval() bool { |
| 89 | if d.Objective != nil || len(d.Added) > 0 { |
| 90 | return true |
| 91 | } |
| 92 | for _, change := range d.Changed { |
| 93 | if grew(change.Before.Risks, change.After.Risks) || |
| 94 | grew(change.Before.Acceptance, change.After.Acceptance) || |
| 95 | grew(change.Before.VerifiedFiles, change.After.VerifiedFiles) || |
| 96 | grew(change.Before.CandidateFiles, change.After.CandidateFiles) { |
| 97 | return true |
| 98 | } |
| 99 | } |
| 100 | return false |
| 101 | } |
| 102 | |
| 103 | func grew[T any](before, after []T) bool { return len(after) > len(before) } |
| 104 | |
| 105 | // Moved reports whether anything actually changed. Preserved alone is not a |
| 106 | // change — it is the reassurance that sits beside one. |
| 107 | func (d Diff) Moved() bool { |
| 108 | return d.Objective != nil || len(d.Added) > 0 || len(d.Removed) > 0 || len(d.Changed) > 0 |
| 109 | } |
| 110 | |
| 111 | // RenderDiff turns the comparison into the summary a reviewer reads. Empty |
| 112 | // sections are omitted, and a revision that changed nothing renders as nothing. |
| 113 | func RenderDiff(d Diff) string { |
| 114 | if !d.Moved() { |
| 115 | return "" |
| 116 | } |
| 117 | var b strings.Builder |
| 118 | if d.FromRevision > 0 && d.ToRevision > 0 { |
| 119 | fmt.Fprintf(&b, "**Revision %d → %d**\n", d.FromRevision, d.ToRevision) |
| 120 | } |
| 121 | if d.Objective != nil { |
| 122 | fmt.Fprintf(&b, "\n**Objective**\n was: %s\n now: %s\n", d.Objective.Before, d.Objective.After) |
| 123 | } |
| 124 | diffSection(&b, "Added", d.Added, func(s Step) string { return s.ID + " " + s.Title }) |
| 125 | diffSection(&b, "Removed", d.Removed, func(s Step) string { return s.ID + " " + s.Title }) |
| 126 | diffSection(&b, "Changed", d.Changed, func(c StepChange) string { |
| 127 | return c.After.ID + " " + c.After.Title + " (" + strings.Join(c.Fields, ", ") + ")" |
| 128 | }) |
| 129 | diffSection(&b, "Preserved", d.Preserved, func(s Step) string { return s.ID + " " + s.Title }) |
| 130 | return strings.TrimSpace(b.String()) |
| 131 | } |
| 132 | |
| 133 | func diffSection[T any](b *strings.Builder, title string, items []T, line func(T) string) { |
| 134 | if len(items) == 0 { |
| 135 | return |
| 136 | } |
| 137 | fmt.Fprintf(b, "\n**%s**\n", title) |
| 138 | for _, item := range items { |
| 139 | fmt.Fprintf(b, " %s\n", line(item)) |
| 140 | } |
| 141 | } |
| 142 |