1package spec23import (4 "os"5 "path/filepath"6 "reflect"7 "slices"8 "strings"9 "testing"10 "time"11)1213var fixtureDir = filepath.Join("..", "..", "testdata", "fixture")1415// copyFixture copies testdata/fixture into a fresh temp dir and returns the16// repo root (the directory containing openspec/).17func copyFixture(t *testing.T) string {18 t.Helper()19 root := filepath.Join(t.TempDir(), "repo")20 if err := os.CopyFS(root, os.DirFS(fixtureDir)); err != nil {21 t.Fatalf("copy fixture: %v", err)22 }23 return root24}2526func mustLoad(t *testing.T, root string) *Snapshot {27 t.Helper()28 s, err := Load(root)29 if err != nil {30 t.Fatalf("Load(%s): %v", root, err)31 }32 return s33}3435func writeFile(t *testing.T, path, content string) {36 t.Helper()37 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {38 t.Fatal(err)39 }40 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {41 t.Fatal(err)42 }43}4445func changeIDs(cs []Change) []string {46 var ids []string47 for _, c := range cs {48 ids = append(ids, c.ID)49 }50 return ids51}5253func TestLoadFixtureCLIParity(t *testing.T) {54 s := mustLoad(t, fixtureDir)5556 if !s.Exists {57 t.Fatal("Exists = false, want true")58 }59 if s.Root != fixtureDir || s.Dir != filepath.Join(fixtureDir, "openspec") {60 t.Errorf("Root, Dir = %q, %q", s.Root, s.Dir)61 }62 if want := filepath.Join(fixtureDir, "openspec", "config.yaml"); s.Config != want {63 t.Errorf("Config = %q, want %q", s.Config, want)64 }6566 // `openspec list --json`: 5 active changes (add-workspace-invites has only67 // .openspec.yaml but is still a change), sorted by ID here.68 wantChanges := []string{"add-csv-export-filters", "add-due-dates", "add-task-search", "add-workspace-invites", "harden-session-security"}69 if got := changeIDs(s.Changes); !reflect.DeepEqual(got, wantChanges) {70 t.Fatalf("Changes = %v, want %v", got, wantChanges)71 }72 wantTasks := map[string]TaskStats{73 "add-csv-export-filters": {Done: 8, Total: 8},74 "add-due-dates": {},75 "add-task-search": {},76 "add-workspace-invites": {},77 "harden-session-security": {Done: 4, Total: 10},78 }79 for _, c := range s.Changes {80 if c.Tasks != wantTasks[c.ID] {81 t.Errorf("%s: Tasks = %+v, want %+v", c.ID, c.Tasks, wantTasks[c.ID])82 }83 if c.Archived || c.ArchivedOn != "" || c.Name != c.ID {84 t.Errorf("%s: Archived=%v ArchivedOn=%q Name=%q for an active change", c.ID, c.Archived, c.ArchivedOn, c.Name)85 }86 if c.Schema != "spec-driven" {87 t.Errorf("%s: Schema = %q, want spec-driven", c.ID, c.Schema)88 }89 if c.Dir != filepath.Join(s.Dir, "changes", c.ID) {90 t.Errorf("%s: Dir = %q", c.ID, c.Dir)91 }92 }9394 // `openspec show <change> --json` deltaCount.95 wantDeltas := map[string]DeltaStats{96 "add-csv-export-filters": {Added: 1, Modified: 1},97 "add-task-search": {Added: 2, Modified: 1},98 "harden-session-security": {Added: 1, Modified: 1, Removed: 1, Renamed: 1},99 }100 for _, c := range s.Changes {101 if c.Deltas != wantDeltas[c.ID] {102 t.Errorf("%s: Deltas = %+v, want %+v", c.ID, c.Deltas, wantDeltas[c.ID])103 }104 }105106 // `openspec spec list --json` requirementCount, sorted by ID.107 type specCount struct {108 ID string109 Requirements, Scenarios int110 }111 var gotSpecs []specCount112 for _, sp := range s.Specs {113 gotSpecs = append(gotSpecs, specCount{sp.ID, sp.Requirements, sp.Scenarios})114 }115 wantSpecs := []specCount{{"data-export", 3, 4}, {"notifications", 2, 4}, {"task-management", 4, 10}, {"user-auth", 4, 7}}116 if !reflect.DeepEqual(gotSpecs, wantSpecs) {117 t.Errorf("Specs = %+v, want %+v", gotSpecs, wantSpecs)118 }119120 // Archive: sorted newest-first with the date prefix split off.121 type arch struct{ ID, On, Name string }122 var gotArch []arch123 for _, c := range s.Archive {124 gotArch = append(gotArch, arch{c.ID, c.ArchivedOn, c.Name})125 if !c.Archived {126 t.Errorf("%s: Archived = false", c.ID)127 }128 if c.Dir != filepath.Join(s.Dir, "changes", "archive", c.ID) {129 t.Errorf("%s: Dir = %q", c.ID, c.Dir)130 }131 }132 wantArch := []arch{133 {"2026-09-23-add-email-notifications", "2026-09-23", "add-email-notifications"},134 {"2026-08-19-add-user-auth", "2026-08-19", "add-user-auth"},135 }136 if !reflect.DeepEqual(gotArch, wantArch) {137 t.Errorf("Archive = %+v, want %+v", gotArch, wantArch)138 }139 if got := s.Archive[0].Tasks; got != (TaskStats{Done: 7, Total: 7}) {140 t.Errorf("add-email-notifications Tasks = %+v, want 7/7", got)141 }142 if got := s.Archive[1].Tasks; got != (TaskStats{Done: 10, Total: 10}) {143 t.Errorf("add-user-auth Tasks = %+v, want 10/10", got)144 }145 if got := s.Archive[0].Deltas; got != (DeltaStats{Added: 2, Modified: 1}) {146 t.Errorf("add-email-notifications Deltas = %+v", got)147 }148}149150func TestLoadFixtureArtifacts(t *testing.T) {151 s := mustLoad(t, fixtureDir)152153 type art struct {154 Kind ArtifactKind155 Rel string156 }157 want := map[string][]art{158 "add-csv-export-filters": {{KindProposal, "proposal.md"}, {KindTasks, "tasks.md"}, {KindDelta, "specs/data-export/spec.md"}},159 "add-due-dates": {{KindProposal, "proposal.md"}, {KindOther, "README.md"}},160 "add-task-search": {{KindProposal, "proposal.md"},161 {KindDelta, "specs/search/spec.md"}, {KindDelta, "specs/task-management/spec.md"}},162 "add-workspace-invites": nil, // .openspec.yaml is metadata, not an artifact163 "harden-session-security": {{KindProposal, "proposal.md"}, {KindDesign, "design.md"},164 {KindTasks, "tasks.md"}, {KindDelta, "specs/user-auth/spec.md"}},165 "2026-08-19-add-user-auth": {{KindProposal, "proposal.md"}, {KindDesign, "design.md"},166 {KindTasks, "tasks.md"}, {KindDelta, "specs/user-auth/spec.md"}},167 "2026-09-23-add-email-notifications": {{KindProposal, "proposal.md"}, {KindTasks, "tasks.md"},168 {KindDelta, "specs/notifications/spec.md"}, {KindDelta, "specs/task-management/spec.md"}},169 }170 for _, c := range append(append([]Change{}, s.Changes...), s.Archive...) {171 var got []art172 for _, a := range c.Artifacts {173 got = append(got, art{a.Kind, a.Rel})174 if a.Path != filepath.Join(c.Dir, filepath.FromSlash(a.Rel)) {175 t.Errorf("%s/%s: Path = %q", c.ID, a.Rel, a.Path)176 }177 if a.Content == "" || a.ModTime.IsZero() {178 t.Errorf("%s/%s: empty Content or zero ModTime", c.ID, a.Rel)179 }180 if a.ModTime.After(c.ModTime) {181 t.Errorf("%s/%s: artifact ModTime %v after change ModTime %v", c.ID, a.Rel, a.ModTime, c.ModTime)182 }183 }184 if !reflect.DeepEqual(got, want[c.ID]) {185 t.Errorf("%s artifacts = %v, want %v", c.ID, got, want[c.ID])186 }187 }188189 harden, ok := s.FindChange("harden-session-security")190 if !ok {191 t.Fatal("FindChange(harden-session-security) not found")192 }193 if !harden.Has(KindDesign) || !harden.Has(KindDelta) {194 t.Error("harden-session-security should have design and delta artifacts")195 }196 if d, _ := harden.Artifact(KindDelta); d.Label() != "Δ user-auth" || d.Capability() != "user-auth" {197 t.Errorf("delta Label, Capability = %q, %q", d.Label(), d.Capability())198 }199 if p, _ := harden.Artifact(KindProposal); p.Label() != "proposal.md" {200 t.Errorf("proposal Label = %q", p.Label())201 }202 csv, _ := s.FindChange("add-csv-export-filters")203 if csv.Has(KindDesign) {204 t.Error("add-csv-export-filters has no design.md")205 }206 if _, ok := csv.Artifact(KindDesign); ok {207 t.Error("Artifact(KindDesign) ok = true for add-csv-export-filters")208 }209 if _, ok := s.FindChange("2026-08-19-add-user-auth"); !ok {210 t.Error("FindChange should find archived changes by ID")211 }212 if _, ok := s.FindChange("nope"); ok {213 t.Error("FindChange(nope) ok = true")214 }215216 kinds := map[ArtifactKind]string{KindProposal: "proposal", KindDesign: "design", KindTasks: "tasks", KindDelta: "delta", KindOther: "other"}217 for k, want := range kinds {218 if k.String() != want {219 t.Errorf("ArtifactKind(%d).String() = %q, want %q", k, k.String(), want)220 }221 }222}223224func TestLoadFixtureSummariesAndSpecs(t *testing.T) {225 s := mustLoad(t, fixtureDir)226227 wantSummary := map[string]string{228 "add-csv-export-filters": "Project owners export to CSV mainly",229 "add-due-dates": "Teams use Tally for release checklists",230 "add-task-search": "Projects with more than a few hundred tasks", // H1 "Change:" title is skipped231 "add-workspace-invites": "",232 "harden-session-security": "A September penetration test flagged",233 }234 for _, c := range s.Changes {235 want := wantSummary[c.ID]236 if (want == "") != (c.Summary == "") || !strings.HasPrefix(c.Summary, want) {237 t.Errorf("%s: Summary = %q, want prefix %q", c.ID, c.Summary, want)238 }239 }240241 notif, ok := s.FindSpec("notifications")242 if !ok {243 t.Fatal("FindSpec(notifications) not found")244 }245 if notif.Title != "notifications Specification" ||246 notif.Purpose != "TBD - created by archiving change add-email-notifications. Update Purpose after archive." {247 t.Errorf("notifications Title, Purpose = %q, %q", notif.Title, notif.Purpose)248 }249 export, _ := s.FindSpec("data-export")250 if export.Title != "Data Export Specification" || !strings.HasPrefix(export.Purpose, "Lets project owners take their data") {251 t.Errorf("data-export Title, Purpose = %q, %q", export.Title, export.Purpose)252 }253 if export.Path != filepath.Join(s.Dir, "specs", "data-export", "spec.md") || export.Content == "" || export.ModTime.IsZero() {254 t.Errorf("data-export Path/Content/ModTime not populated: %q", export.Path)255 }256 if _, ok := s.FindSpec("search"); ok {257 t.Error("FindSpec(search) ok = true; search only exists as a delta")258 }259}260261func TestLoadChangeModTimeIsNewestFile(t *testing.T) {262 root := copyFixture(t)263 before, _ := mustLoad(t, root).FindChange("add-task-search")264 newest := before.ModTime.Add(time.Hour).Truncate(time.Second)265 nested := filepath.Join(before.Dir, "specs", "search", "spec.md")266 if err := os.Chtimes(nested, newest, newest); err != nil {267 t.Fatal(err)268 }269 c, _ := mustLoad(t, root).FindChange("add-task-search")270 if !c.ModTime.Equal(newest) {271 t.Errorf("ModTime = %v, want nested delta mtime %v", c.ModTime, newest)272 }273}274275func TestLoadWithoutOpenspecDir(t *testing.T) {276 root := t.TempDir()277 s, err := Load(root)278 if err != nil {279 t.Fatalf("Load: %v", err)280 }281 if s.Exists || s.Root != root || s.Dir != filepath.Join(root, "openspec") {282 t.Errorf("got Exists=%v Root=%q Dir=%q", s.Exists, s.Root, s.Dir)283 }284 if len(s.Changes)+len(s.Archive)+len(s.Specs) != 0 || s.Config != "" {285 t.Errorf("expected an empty snapshot, got %+v", s)286 }287}288289func TestLoadOpenspecIsAFile(t *testing.T) {290 root := t.TempDir()291 writeFile(t, filepath.Join(root, "openspec"), "not a dir")292 if _, err := Load(root); err == nil {293 t.Error("Load succeeded when openspec is a file")294 }295}296297// A fresh clone has no empty dirs: openspec/ may lack specs/, changes/ and298// changes/archive/.299func TestLoadMissingSubdirs(t *testing.T) {300 root := t.TempDir()301 if err := os.Mkdir(filepath.Join(root, "openspec"), 0o755); err != nil {302 t.Fatal(err)303 }304 s := mustLoad(t, root)305 if !s.Exists || len(s.Changes)+len(s.Archive)+len(s.Specs) != 0 || s.Config != "" {306 t.Errorf("got %+v", s)307 }308309 // Legacy project.md is used when config.yaml is absent; changes without310 // an archive dir still load.311 writeFile(t, filepath.Join(root, "openspec", "project.md"), "# Project\n")312 writeFile(t, filepath.Join(root, "openspec", "changes", "solo", "proposal.md"), "## Why\nSolo.\n")313 s = mustLoad(t, root)314 if s.Config != filepath.Join(root, "openspec", "project.md") {315 t.Errorf("Config = %q, want project.md", s.Config)316 }317 if got := changeIDs(s.Changes); !reflect.DeepEqual(got, []string{"solo"}) || s.Changes[0].Schema != "" {318 t.Errorf("Changes = %v (schema %q)", got, s.Changes[0].Schema)319 }320}321322func TestLoadSkipsDotDirsAndArchiveWithoutDatePrefix(t *testing.T) {323 root := t.TempDir()324 changes := filepath.Join(root, "openspec", "changes")325 writeFile(t, filepath.Join(changes, ".scratch", "proposal.md"), "## Why\nx\n")326 writeFile(t, filepath.Join(changes, "stray.md"), "not a change dir\n")327 writeFile(t, filepath.Join(changes, "archive", "manually-moved", "proposal.md"), "## Why\nx\n")328 s := mustLoad(t, root)329 if len(s.Changes) != 0 {330 t.Errorf("Changes = %v, want none (dot-dirs and files skipped)", changeIDs(s.Changes))331 }332 if len(s.Archive) != 1 || s.Archive[0].Name != "manually-moved" || s.Archive[0].ArchivedOn != "" {333 t.Errorf("Archive = %+v", s.Archive)334 }335}336337// The CLI only reads deltas from specs/<capability>/spec.md (change-parser.js338// parseDeltaSpecs iterates directories under specs/). Verified:339// `openspec show <change> --json` reports deltaCount 0 for this layout.340func TestLoadDeltaNeedsCapabilityDir(t *testing.T) {341 root := t.TempDir()342 writeFile(t, filepath.Join(root, "openspec", "changes", "loose", "specs", "spec.md"),343 "## ADDED Requirements\n### Requirement: Loose\nThe system SHALL x.\n")344 c := mustLoad(t, root).Changes[0]345 if a := c.Artifacts[0]; a.Kind == KindDelta {346 t.Errorf("specs/spec.md classified as delta (Capability %q)", a.Capability())347 }348 if c.Deltas.Total() != 0 {349 t.Errorf("Deltas = %+v, want none", c.Deltas)350 }351}352353func TestFindRoot(t *testing.T) {354 base, err := filepath.EvalSymlinks(t.TempDir()) // /var -> /private/var on macOS355 if err != nil {356 t.Fatal(err)357 }358 root := filepath.Join(base, "repo")359 if err := os.CopyFS(root, os.DirFS(fixtureDir)); err != nil {360 t.Fatal(err)361 }362 deep := filepath.Join(root, "src", "pkg", "deep")363 if err := os.MkdirAll(deep, 0o755); err != nil {364 t.Fatal(err)365 }366 outside := filepath.Join(base, "elsewhere", "nested")367 if err := os.MkdirAll(outside, 0o755); err != nil {368 t.Fatal(err)369 }370371 tests := []struct {372 name, start, want string373 }{374 {"root itself", root, root},375 {"nested subdir", deep, root},376 {"inside openspec/changes/<id>", filepath.Join(root, "openspec", "changes", "add-due-dates"), root},377 {"relative path with dots", filepath.Join(deep, "..", ".."), root},378 {"no openspec anywhere returns start", outside, outside},379 }380 for _, tt := range tests {381 t.Run(tt.name, func(t *testing.T) {382 got, err := FindRoot(tt.start)383 if err != nil {384 t.Fatalf("FindRoot: %v", err)385 }386 if got != tt.want {387 t.Errorf("FindRoot(%q) = %q, want %q", tt.start, got, tt.want)388 }389 })390 }391392 t.Run("relative to cwd", func(t *testing.T) {393 t.Chdir(deep)394 got, err := FindRoot(".")395 if err != nil || got != root {396 t.Errorf("FindRoot(.) = %q, %v; want %q", got, err, root)397 }398 })399400 t.Run("symlinked start resolves to real path", func(t *testing.T) {401 link := filepath.Join(base, "link")402 if err := os.Symlink(deep, link); err != nil {403 t.Skip("symlinks unsupported:", err)404 }405 if got, err := FindRoot(link); err != nil || got != root {406 t.Errorf("FindRoot(link) = %q, %v; want %q", got, err, root)407 }408 })409410 t.Run("display keeps a symlinked root as typed", func(t *testing.T) {411 link := filepath.Join(base, "repo-link")412 if err := os.Symlink(root, link); err != nil {413 t.Skip("symlinks unsupported:", err)414 }415 start := filepath.Join(link, "src", "pkg")416 got, err := FindRoot(start)417 if err != nil || got != root {418 t.Fatalf("FindRoot(%q) = %q, %v; want %q", start, got, err, root)419 }420 if d := DisplayRoot(start, got); d != link {421 t.Errorf("DisplayRoot = %q, want %q", d, link)422 }423 if d := DisplayRoot(deep, got); d != root {424 t.Errorf("DisplayRoot without symlinks = %q, want %q", d, root)425 }426 })427}428429func TestArchiveName(t *testing.T) {430 for id, want := range map[string]string{431 "2026-10-13-add-auth": "add-auth",432 "add-auth": "add-auth",433 "2026-10-13": "2026-10-13",434 } {435 if got := ArchiveName(id); got != want {436 t.Errorf("ArchiveName(%q) = %q, want %q", id, got, want)437 }438 }439}440441// One unreadable directory hides only its own contents.442func TestLoadSkipsUnreadableDirs(t *testing.T) {443 if os.Geteuid() == 0 {444 t.Skip("root ignores directory permissions")445 }446 root := copyFixture(t)447 locked := filepath.Join(root, "openspec", "specs", "notifications")448 if err := os.Chmod(locked, 0); err != nil {449 t.Fatal(err)450 }451 t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })452 s := mustLoad(t, root)453 if len(s.Specs) != 3 || len(s.Changes) != 5 {454 t.Errorf("Specs = %d, Changes = %d; want 3 readable specs and 5 changes", len(s.Specs), len(s.Changes))455 }456}457458// Two changes archived the same day: the one edited last comes first.459func TestArchiveSortsNewestFirstWithinADay(t *testing.T) {460 root := copyFixture(t)461 archive := filepath.Join(root, "openspec", "changes", "archive")462 older := filepath.Join(archive, "2026-10-13-zz-older", "tasks.md")463 newer := filepath.Join(archive, "2026-10-13-aa-newer", "tasks.md")464 writeFile(t, older, "- [x] done\n")465 writeFile(t, newer, "- [x] done\n")466 past := time.Now().Add(-time.Hour)467 if err := os.Chtimes(older, past, past); err != nil {468 t.Fatal(err)469 }470 if err := os.Chtimes(filepath.Dir(older), past, past); err != nil {471 t.Fatal(err)472 }473 var got []string474 for _, c := range mustLoad(t, root).Archive {475 got = append(got, c.ID)476 }477 want := []string{"2026-10-13-aa-newer", "2026-10-13-zz-older", "2026-09-23-add-email-notifications", "2026-08-19-add-user-auth"}478 if !slices.Equal(got, want) {479 t.Errorf("archive order = %v, want %v", got, want)480 }481}