1package spec23import (4 "os"5 "path/filepath"6 "reflect"7 "slices"8 "strings"9 "testing"10 "time"11)1213// ev is the comparable part of an Event (Time is checked separately).14type ev struct {15 Kind EventKind16 Item ItemKind17 ID string18 Rel string19 Detail string20}2122func strip(t *testing.T, events []Event, now time.Time) []ev {23 t.Helper()24 var out []ev25 for _, e := range events {26 if !e.Time.Equal(now) {27 t.Errorf("event %+v: Time = %v, want %v", e, e.Time, now)28 }29 out = append(out, ev{e.Kind, e.Item, e.ID, e.Rel, e.Detail})30 }31 return out32}3334func assertEvents(t *testing.T, got []Event, now time.Time, want []ev) {35 t.Helper()36 if g := strip(t, got, now); !reflect.DeepEqual(g, want) {37 t.Errorf("Diff events mismatch\n got: %s\nwant: %s", fmtEvents(g), fmtEvents(want))38 }39}4041func fmtEvents(es []ev) string {42 var b strings.Builder43 for _, e := range es {44 b.WriteString("\n ")45 b.WriteString(strings.Join([]string{46 []string{"Created", "Edited", "Progress", "Archived", "Removed"}[e.Kind],47 []string{"Change", "Spec", "Archive"}[e.Item], e.ID, e.Rel, e.Detail,48 }, " | "))49 }50 return b.String()51}5253// touch rewrites a file (or creates it) and pushes its mtime forward so the54// change is visible even on filesystems with coarse timestamps.55func touch(t *testing.T, path, content string) {56 t.Helper()57 writeFile(t, path, content)58 bump(t, path)59}6061func bump(t *testing.T, path string) {62 t.Helper()63 fi, err := os.Stat(path)64 if err != nil {65 t.Fatal(err)66 }67 later := fi.ModTime().Add(time.Hour)68 if err := os.Chtimes(path, later, later); err != nil {69 t.Fatal(err)70 }71}7273func appendTo(t *testing.T, path, extra string) {74 t.Helper()75 b, err := os.ReadFile(path)76 if err != nil {77 t.Fatal(err)78 }79 touch(t, path, string(b)+extra)80}8182const newRequirement = `83### Requirement: Excel export84The system SHALL export a project's tasks as an XLSX workbook.8586#### Scenario: Export as XLSX87- **WHEN** a project owner requests an XLSX export88- **THEN** the system returns a workbook with one row per task89`9091func TestDiffAgentEdits(t *testing.T) {92 root := copyFixture(t)93 changes := filepath.Join(root, "openspec", "changes")94 prev := mustLoad(t, root)9596 // 1. Check task 2.2 (the first unchecked top-level task).97 tasks := filepath.Join(changes, "harden-session-security", "tasks.md")98 b, err := os.ReadFile(tasks)99 if err != nil {100 t.Fatal(err)101 }102 checked := strings.Replace(string(b), "- [ ] 2.2", "- [x] 2.2", 1)103 if checked == string(b) {104 t.Fatal("task 2.2 not found")105 }106 touch(t, tasks, checked)107108 // 2. A new change dir with a proposal.109 touch(t, filepath.Join(changes, "add-task-comments", ".openspec.yaml"), "schema: spec-driven\ncreated: 2026-10-13\n")110 touch(t, filepath.Join(changes, "add-task-comments", "proposal.md"), "## Why\n\nMembers discuss tasks in Slack.\n")111112 // 3. design.md added to an existing change.113 touch(t, filepath.Join(changes, "add-task-search", "design.md"), "## Context\n\nPostgres FTS.\n")114115 // 4. A main spec gains a requirement.116 appendTo(t, filepath.Join(root, "openspec", "specs", "data-export", "spec.md"), newRequirement)117118 // 5. Archive the completed change.119 if err := os.Rename(filepath.Join(changes, "add-csv-export-filters"),120 filepath.Join(changes, "archive", "2026-10-13-add-csv-export-filters")); err != nil {121 t.Fatal(err)122 }123124 // 6. Delete an artifact.125 if err := os.Remove(filepath.Join(changes, "harden-session-security", "design.md")); err != nil {126 t.Fatal(err)127 }128129 next := mustLoad(t, root)130 now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)131 assertEvents(t, Diff(prev, next, now), now, []ev{132 {EventCreated, ItemChange, "add-task-comments", "", "new change"},133 {EventCreated, ItemChange, "add-task-search", "design.md", "design.md"},134 {EventProgress, ItemChange, "harden-session-security", "tasks.md", "tasks 4/10 → 5/10"},135 {EventRemoved, ItemChange, "harden-session-security", "", "design.md"},136 {EventArchived, ItemArchive, "2026-10-13-add-csv-export-filters", "", "archived"},137 {EventEdited, ItemSpec, "data-export", "spec.md", "requirements 3 → 4"},138 })139140 // Sanity: the snapshot itself reflects the mutations.141 if h, _ := next.FindChange("harden-session-security"); h.Tasks != (TaskStats{Done: 5, Total: 10}) || h.Has(KindDesign) {142 t.Errorf("harden-session-security after edits: Tasks=%+v Has(design)=%v", h.Tasks, h.Has(KindDesign))143 }144 if a, ok := next.FindChange("2026-10-13-add-csv-export-filters"); !ok || !a.Archived || a.Name != "add-csv-export-filters" {145 t.Errorf("archived change = %+v, %v", a, ok)146 }147 if len(next.Changes) != 5 || len(next.Archive) != 3 || next.Archive[0].ID != "2026-10-13-add-csv-export-filters" {148 t.Errorf("Changes=%v Archive=%v", changeIDs(next.Changes), changeIDs(next.Archive))149 }150151 // Reloading without further edits yields no events.152 again := mustLoad(t, root)153 if evs := Diff(next, again, now); len(evs) != 0 {154 t.Errorf("Diff of unchanged reload = %s", fmtEvents(strip(t, evs, now)))155 }156}157158func TestDiffEditsAndRemovals(t *testing.T) {159 root := copyFixture(t)160 osDir := filepath.Join(root, "openspec")161 changes := filepath.Join(osDir, "changes")162 prev := mustLoad(t, root)163164 // Content edit with no structural change.165 appendTo(t, filepath.Join(changes, "harden-session-security", "design.md"), "\n- Decided: confirm before revoking the current session.\n")166 // tasks.md rewritten without changing progress.167 appendTo(t, filepath.Join(changes, "add-csv-export-filters", "tasks.md"), "\nNotes: shipped behind a flag.\n")168 // A delta spec gains a requirement: reported as an artifact edit.169 appendTo(t, filepath.Join(changes, "add-task-search", "specs", "search", "spec.md"), newRequirement)170 // Metadata-only change: .openspec.yaml is not an artifact, so no event.171 touch(t, filepath.Join(changes, "add-due-dates", ".openspec.yaml"), "schema: spec-driven\ncreated: 2026-10-11\n")172 // A whole change deleted (not archived).173 if err := os.RemoveAll(filepath.Join(changes, "add-workspace-invites")); err != nil {174 t.Fatal(err)175 }176 // A main spec touched without changing its requirement count.177 bump(t, filepath.Join(osDir, "specs", "user-auth", "spec.md"))178 // A new main spec and a removed one.179 touch(t, filepath.Join(osDir, "specs", "search", "spec.md"),180 "# search Specification\n\n## Purpose\nSearch.\n## Requirements\n### Requirement: A\n### Requirement: B\n")181 if err := os.RemoveAll(filepath.Join(osDir, "specs", "notifications")); err != nil {182 t.Fatal(err)183 }184185 next := mustLoad(t, root)186 now := time.Date(2026, 10, 13, 12, 0, 1, 0, time.UTC)187 assertEvents(t, Diff(prev, next, now), now, []ev{188 {EventEdited, ItemChange, "add-csv-export-filters", "tasks.md", "tasks.md"},189 {EventEdited, ItemChange, "add-task-search", "specs/search/spec.md", "specs/search/spec.md"},190 {EventEdited, ItemChange, "harden-session-security", "design.md", "design.md"},191 {EventRemoved, ItemChange, "add-workspace-invites", "", "change removed"},192 {EventCreated, ItemSpec, "search", "spec.md", "new spec, 2 requirements"},193 {EventEdited, ItemSpec, "user-auth", "spec.md", "spec.md"},194 {EventRemoved, ItemSpec, "notifications", "", "spec removed"},195 })196}197198func TestDiffNilAndIdentical(t *testing.T) {199 s := mustLoad(t, fixtureDir)200 now := time.Now()201 if got := Diff(nil, s, now); got != nil {202 t.Errorf("Diff(nil, s) = %v, want nil", got)203 }204 if got := Diff(s, nil, now); got != nil {205 t.Errorf("Diff(s, nil) = %v, want nil", got)206 }207 if got := Diff(s, s, now); len(got) != 0 {208 t.Errorf("Diff(s, s) = %v, want none", got)209 }210 // openspec/ appearing from nothing: every change and spec is new.211 empty := mustLoad(t, t.TempDir())212 got := strip(t, Diff(empty, s, now), now)213 if len(got) != len(s.Changes)+len(s.Specs) {214 t.Errorf("Diff(empty, fixture) = %d events, want %d: %s", len(got), len(s.Changes)+len(s.Specs), fmtEvents(got))215 }216 if len(got) > 0 && got[0] != (ev{EventCreated, ItemChange, "add-csv-export-filters", "", "new change"}) {217 t.Errorf("first event = %+v", got[0])218 }219}220221// Re-using a change name after an earlier archive is allowed by the CLI222// (`new change` only checks changes/<name>, and archive dirs differ by date).223func TestDiffArchiveWithReusedName(t *testing.T) {224 root := copyFixture(t)225 changes := filepath.Join(root, "openspec", "changes")226 touch(t, filepath.Join(changes, "archive", "2026-08-01-add-due-dates", "proposal.md"), "## Why\nFirst attempt.\n")227 prev := mustLoad(t, root)228229 if err := os.Rename(filepath.Join(changes, "add-due-dates"), filepath.Join(changes, "archive", "2026-10-13-add-due-dates")); err != nil {230 t.Fatal(err)231 }232 next := mustLoad(t, root)233 now := time.Now()234 assertEvents(t, Diff(prev, next, now), now, []ev{235 {EventArchived, ItemArchive, "2026-10-13-add-due-dates", "", "archived"},236 })237}238239func TestFingerprintChanged(t *testing.T) {240 root := copyFixture(t)241 dir := filepath.Join(root, "openspec")242243 fp := Scan(dir)244 proposal := filepath.Join(dir, "changes", "add-due-dates", "proposal.md")245 yaml := filepath.Join(dir, "changes", "add-workspace-invites", ".openspec.yaml")246 for _, p := range []string{proposal, yaml, filepath.Join(dir, "config.yaml")} {247 if _, ok := fp[p]; !ok {248 t.Errorf("Scan missing file %s", p)249 }250 }251 for _, p := range []string{dir, filepath.Join(dir, "changes"), filepath.Join(dir, "changes", "archive")} {252 if _, ok := fp[p]; !ok {253 t.Errorf("Scan missing directory %s", p)254 }255 }256 if got := fp.Changed(Scan(dir)); len(got) != 0 {257 t.Errorf("Changed with no edits = %v", got)258 }259260 tests := []struct {261 name string262 mutate func(t *testing.T) []string263 }{264 {"add", func(t *testing.T) []string {265 p := filepath.Join(dir, "changes", "add-due-dates", "design.md")266 writeFile(t, p, "## Context\n")267 return []string{p}268 }},269 {"modify content", func(t *testing.T) []string {270 appendTo(t, proposal, "\nMore.\n")271 return []string{proposal}272 }},273 {"modify mtime only", func(t *testing.T) []string {274 bump(t, yaml)275 return []string{yaml}276 }},277 {"modify size only", func(t *testing.T) []string {278 fi, _ := os.Stat(yaml)279 writeFile(t, yaml, "schema: x\n")280 if err := os.Chtimes(yaml, fi.ModTime(), fi.ModTime()); err != nil {281 t.Fatal(err)282 }283 return []string{yaml}284 }},285 {"delete", func(t *testing.T) []string {286 p := filepath.Join(dir, "specs", "notifications", "spec.md")287 if err := os.Remove(p); err != nil {288 t.Fatal(err)289 }290 return []string{p}291 }},292 {"new empty dir", func(t *testing.T) []string {293 p := filepath.Join(dir, "changes", "scaffolded")294 if err := os.Mkdir(p, 0o755); err != nil {295 t.Fatal(err)296 }297 return []string{p}298 }},299 {"rename dir (archive)", func(t *testing.T) []string {300 from := filepath.Join(dir, "changes", "add-task-search")301 to := filepath.Join(dir, "changes", "archive", "2026-10-13-add-task-search")302 if err := os.Rename(from, to); err != nil {303 t.Fatal(err)304 }305 var want []string306 for _, rel := range []string{"", ".openspec.yaml", "proposal.md", "specs", "specs/search", "specs/search/spec.md",307 "specs/task-management", "specs/task-management/spec.md"} {308 want = append(want, filepath.Join(from, rel), filepath.Join(to, rel))309 }310 return want311 }},312 }313 for _, tt := range tests {314 t.Run(tt.name, func(t *testing.T) {315 before := Scan(dir)316 want := tt.mutate(t)317 got := before.Changed(Scan(dir))318 slices.Sort(got)319 slices.Sort(want)320 if !reflect.DeepEqual(got, want) {321 t.Errorf("Changed() = %v, want %v", got, want)322 }323 })324 }325}326327func TestScanMissingDir(t *testing.T) {328 fp := Scan(filepath.Join(t.TempDir(), "nope"))329 if fp == nil || len(fp) != 0 {330 t.Errorf("Scan(missing) = %v, want empty non-nil", fp)331 }332 full := Scan(filepath.Join(fixtureDir, "openspec"))333 got := fp.Changed(full)334 if len(got) != len(full) || len(got) == 0 {335 t.Errorf("Changed(empty -> fixture) = %d paths, want %d", len(got), len(full))336 }337 if back := full.Changed(fp); len(back) != len(full) {338 t.Errorf("Changed(fixture -> empty) = %d paths, want %d", len(back), len(full))339 }340}341342// Archiving by copy-then-delete: the archive copy shows up in one snapshot343// while the change is still active, and the change vanishes in the next.344func TestDiffArchiveByCopyThenDelete(t *testing.T) {345 root := copyFixture(t)346 changes := filepath.Join(root, "openspec", "changes")347 if err := os.CopyFS(filepath.Join(changes, "archive", "2026-10-13-add-due-dates"), os.DirFS(filepath.Join(changes, "add-due-dates"))); err != nil {348 t.Fatal(err)349 }350 prev := mustLoad(t, root)351 if err := os.RemoveAll(filepath.Join(changes, "add-due-dates")); err != nil {352 t.Fatal(err)353 }354 now := time.Now()355 assertEvents(t, Diff(prev, mustLoad(t, root), now), now, []ev{356 {EventArchived, ItemArchive, "2026-10-13-add-due-dates", "", "archived"},357 })358}