返回 DeepSeek-Reasonix
diff.go
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
142 lines GO