clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

Add spec tests; fix fence, rename, requirement, summary, schema, and archive-diff parsing

Dami committed 87fede3
commit
87fede3fcfa50c87cf2e0709e80ae89d186f8c14
parent
fb8fe5f4c949d5ae925873c2d0e74be46ee0c228
author
Dami <dami.etoile@gmail.com>
10 files changed, +1271 -30
AREADME.md78++++++
Minternal/spec/diff.go14+-
Ainternal/spec/diff_test.go335++++++++++++++++++++++++
Minternal/spec/load.go3+-
Ainternal/spec/load_test.go411++++++++++++++++++++++++++++++
Minternal/spec/parse.go50+++-
Ainternal/spec/parse_test.go383++++++++++++++++++++++++++++
Minternal/ui/mainpane.go4-
Minternal/ui/render.go8+-
Mmain.go15+-
A README.md +78 -0
@@ -0,0 +1,78 @@
1+# lazyopenspec2+3+A lazygit-style terminal viewer for [OpenSpec](https://github.com/Fission-AI/OpenSpec) repos. Leave it open in a4+split while agents work. It shows each change's artifacts, task progress, and spec deltas updating live, with an5+activity feed of what was touched.6+7+```8+ lazyopenspec │ ~/code/tally │ main +3 uncommitted │ 5 active, 1 ready to archive FOLLOW │ ● last edit just now9+╭─[1] Changes─────────────────────────────╮╭─Change harden-session-security───────────────────────────────────────╮10+│ add-csv-export-filters PDST ▰▰▰▰▰ 8/8││ harden-session-security spec-driven edited 2s ago │11+│▌● harden-session-security M PDST ▰▰▱▱ 5/10││ ✓ proposal ✓ design ✓ specs ✓ tasks ▰▰▰▰▰▱▱▱▱▱▱▱ 5/10 Δ +1 ~1 -1 →1 │12+╭─[4] Activity────────────────────────────╮│ proposal.md │ design.md │ ● tasks.md M │ Δ user-auth │13+│ 14:05:25 ▸ harden-session… tasks 4/10 → 5/10 ...14+```15+16+## Install / run17+18+```sh19+go install . # or: go build -o lazyopenspec .20+lazyopenspec # finds openspec/ in the current dir or any parent21+lazyopenspec ~/code/repo # or point it at a repo22+lazyopenspec --follow # start in follow mode23+lazyopenspec --interval 1s # poll less often (default 400ms)24+```25+26+Requires Go 1.27+. `git` is optional: without it, the uncommitted markers and diff view are unavailable.27+28+## What it shows29+30+- **Changes**: every active change under `openspec/changes/`. `PDST` lights up for proposal, design, spec deltas,31+ and tasks. The bar and count use the same top-level checkbox rule as `openspec list`, so the numbers match the CLI.32+- **Specs**: main specs with requirement counts. `Δn` means n active changes have a delta against that spec. Open33+ the spec and use `]` to read the pending deltas next to it.34+- **Archive**: archived changes, newest first.35+- **Activity**: a feed of new changes, added, edited, or removed artifacts, task progress (`tasks 4/10 → 5/10`),36+ requirement count changes, and archives. `enter` jumps to the item.37+- **Main pane**: the selected file rendered as markdown, with a tab per artifact. `d` switches to `git diff HEAD`38+ for that file (untracked files show in full), so you see what an agent changed since the last commit.39+40+Freshness: `●` bright = edited in the last 15s, dim = last 2 minutes. `M` / `+` = uncommitted / untracked in git.41+42+## Keys43+44+| key | action |45+|---|---|46+| `tab` / `shift+tab`, `1`-`4`, `0` | switch panel (`0` = main pane) |47+| `j` `k` / arrows, `g` `G` | move selection, or scroll when the main pane is focused |48+| `enter` / `l`, `esc` / `h` | focus main pane / back to list |49+| `J` `K`, `ctrl+d` `ctrl+u` | scroll the main pane from anywhere |50+| `[` `]` | previous / next file tab |51+| `d` | toggle git diff vs HEAD |52+| `f` | follow mode: jump to the item and file the agents touch next |53+| `s` | sort changes by name / most recently edited |54+| `z` | zoom the main pane |55+| `e` | open the current file in `$VISUAL` / `$EDITOR` |56+| `c` | clear the activity feed |57+| `r` | force reload |58+| `?` | help |59+| `q` | quit |60+61+Mouse: wheel scrolls the pane under the pointer, and clicking selects a row.62+63+## How it watches64+65+It does a stat-only walk of `openspec/` every `--interval` and re-parses only when something changed. It doesn't66+use fsnotify because on macOS kqueue holds an open file descriptor per watched file. Polling also picks up67+atomic write-then-rename saves, which agents and editors use all the time. Scroll position is kept per file, so a68+live edit to the file you're reading doesn't send you back to the top.69+70+It reads the files directly and doesn't shell out to the `openspec` CLI. The CLI takes about 100ms per call, mostly71+Node startup, and can't read archived changes. See `docs/openspec-format.md` for the format notes it's based on.72+73+## Try it with a fake swarm74+75+```sh76+scripts/swarm-sim.sh /tmp/lazyopenspec-demo # copies testdata/fixture and starts simulated agents77+go run . /tmp/lazyopenspec-demo --follow # in another pane78+```
M internal/spec/diff.go +8 -6
A internal/spec/diff_test.go +335 -0
@@ -0,0 +1,335 @@
1+package spec2+3+import (4+ "os"5+ "path/filepath"6+ "reflect"7+ "slices"8+ "strings"9+ "testing"10+ "time"11+)12+13+// ev is the comparable part of an Event (Time is checked separately).14+type ev struct {15+ Kind EventKind16+ Item ItemKind17+ ID string18+ Rel string19+ Detail string20+}21+22+func 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+}33+34+func 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+}40+41+func 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+}52+53+// touch rewrites a file (or creates it) and pushes its mtime forward so the54+// change is visible even on filesystems with coarse timestamps.55+func touch(t *testing.T, path, content string) {56+ t.Helper()57+ writeFile(t, path, content)58+ bump(t, path)59+}60+61+func 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+}72+73+func 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+}81+82+const newRequirement = `83+### Requirement: Excel export84+The system SHALL export a project's tasks as an XLSX workbook.85+86+#### Scenario: Export as XLSX87+- **WHEN** a project owner requests an XLSX export88+- **THEN** the system returns a workbook with one row per task89+`90+91+func TestDiffAgentEdits(t *testing.T) {92+ root := copyFixture(t)93+ changes := filepath.Join(root, "openspec", "changes")94+ prev := mustLoad(t, root)95+96+ // 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)107+108+ // 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")111+112+ // 3. design.md added to an existing change.113+ touch(t, filepath.Join(changes, "add-task-search", "design.md"), "## Context\n\nPostgres FTS.\n")114+115+ // 4. A main spec gains a requirement.116+ appendTo(t, filepath.Join(root, "openspec", "specs", "data-export", "spec.md"), newRequirement)117+118+ // 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+ }123+124+ // 6. Delete an artifact.125+ if err := os.Remove(filepath.Join(changes, "harden-session-security", "design.md")); err != nil {126+ t.Fatal(err)127+ }128+129+ 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+ })139+140+ // 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+ }150+151+ // 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+}157+158+func 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)163+164+ // 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+ }184+185+ 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+}197+198+func 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+}220+221+// 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).223+func TestDiffArchiveWithReusedName(t *testing.T) {224+ t.Log("was BUG: diff.go:55-57 archivedNames maps Name -> ID and is filled from next.Archive (newest-first), so " +225+ "an older archive with the same Name overwrites the new one; the check at diff.go:74 then finds the OLD " +226+ "ID in prevArchive and reports EventRemoved \"change removed\" instead of EventArchived")227+ root := copyFixture(t)228+ changes := filepath.Join(root, "openspec", "changes")229+ touch(t, filepath.Join(changes, "archive", "2026-08-01-add-due-dates", "proposal.md"), "## Why\nFirst attempt.\n")230+ prev := mustLoad(t, root)231+232+ if err := os.Rename(filepath.Join(changes, "add-due-dates"), filepath.Join(changes, "archive", "2026-10-13-add-due-dates")); err != nil {233+ t.Fatal(err)234+ }235+ next := mustLoad(t, root)236+ now := time.Now()237+ assertEvents(t, Diff(prev, next, now), now, []ev{238+ {EventArchived, ItemArchive, "2026-10-13-add-due-dates", "", "archived"},239+ })240+}241+242+func TestFingerprintChanged(t *testing.T) {243+ root := copyFixture(t)244+ dir := filepath.Join(root, "openspec")245+246+ fp := Scan(dir)247+ proposal := filepath.Join(dir, "changes", "add-due-dates", "proposal.md")248+ yaml := filepath.Join(dir, "changes", "add-workspace-invites", ".openspec.yaml")249+ for _, p := range []string{proposal, yaml, filepath.Join(dir, "config.yaml")} {250+ if _, ok := fp[p]; !ok {251+ t.Errorf("Scan missing file %s", p)252+ }253+ }254+ for _, p := range []string{dir, filepath.Join(dir, "changes"), filepath.Join(dir, "changes", "archive")} {255+ if _, ok := fp[p]; ok {256+ t.Errorf("Scan includes directory %s", p)257+ }258+ }259+ if got := fp.Changed(Scan(dir)); len(got) != 0 {260+ t.Errorf("Changed with no edits = %v", got)261+ }262+263+ tests := []struct {264+ name string265+ mutate func(t *testing.T) []string266+ }{267+ {"add", func(t *testing.T) []string {268+ p := filepath.Join(dir, "changes", "add-due-dates", "design.md")269+ writeFile(t, p, "## Context\n")270+ return []string{p}271+ }},272+ {"modify content", func(t *testing.T) []string {273+ appendTo(t, proposal, "\nMore.\n")274+ return []string{proposal}275+ }},276+ {"modify mtime only", func(t *testing.T) []string {277+ bump(t, yaml)278+ return []string{yaml}279+ }},280+ {"modify size only", func(t *testing.T) []string {281+ fi, _ := os.Stat(yaml)282+ writeFile(t, yaml, "schema: x\n")283+ if err := os.Chtimes(yaml, fi.ModTime(), fi.ModTime()); err != nil {284+ t.Fatal(err)285+ }286+ return []string{yaml}287+ }},288+ {"delete", func(t *testing.T) []string {289+ p := filepath.Join(dir, "specs", "notifications", "spec.md")290+ if err := os.Remove(p); err != nil {291+ t.Fatal(err)292+ }293+ return []string{p}294+ }},295+ {"rename dir (archive)", func(t *testing.T) []string {296+ from := filepath.Join(dir, "changes", "add-task-search")297+ to := filepath.Join(dir, "changes", "archive", "2026-10-13-add-task-search")298+ if err := os.Rename(from, to); err != nil {299+ t.Fatal(err)300+ }301+ var want []string302+ for _, rel := range []string{".openspec.yaml", "proposal.md", "specs/search/spec.md", "specs/task-management/spec.md"} {303+ want = append(want, filepath.Join(from, rel), filepath.Join(to, rel))304+ }305+ return want306+ }},307+ }308+ for _, tt := range tests {309+ t.Run(tt.name, func(t *testing.T) {310+ before := Scan(dir)311+ want := tt.mutate(t)312+ got := before.Changed(Scan(dir))313+ slices.Sort(got)314+ slices.Sort(want)315+ if !reflect.DeepEqual(got, want) {316+ t.Errorf("Changed() = %v, want %v", got, want)317+ }318+ })319+ }320+}321+322+func TestScanMissingDir(t *testing.T) {323+ fp := Scan(filepath.Join(t.TempDir(), "nope"))324+ if fp == nil || len(fp) != 0 {325+ t.Errorf("Scan(missing) = %v, want empty non-nil", fp)326+ }327+ full := Scan(filepath.Join(fixtureDir, "openspec"))328+ got := fp.Changed(full)329+ if len(got) != len(full) || len(got) == 0 {330+ t.Errorf("Changed(empty -> fixture) = %d paths, want %d", len(got), len(full))331+ }332+ if back := full.Changed(fp); len(back) != len(full) {333+ t.Errorf("Changed(fixture -> empty) = %d paths, want %d", len(back), len(full))334+ }335+}
M internal/spec/load.go +2 -1
A internal/spec/load_test.go +411 -0
@@ -0,0 +1,411 @@
1+package spec2+3+import (4+ "os"5+ "path/filepath"6+ "reflect"7+ "strings"8+ "testing"9+ "time"10+)11+12+var fixtureDir = filepath.Join("..", "..", "testdata", "fixture")13+14+// copyFixture copies testdata/fixture into a fresh temp dir and returns the15+// repo root (the directory containing openspec/).16+func copyFixture(t *testing.T) string {17+ t.Helper()18+ root := filepath.Join(t.TempDir(), "repo")19+ if err := os.CopyFS(root, os.DirFS(fixtureDir)); err != nil {20+ t.Fatalf("copy fixture: %v", err)21+ }22+ return root23+}24+25+func mustLoad(t *testing.T, root string) *Snapshot {26+ t.Helper()27+ s, err := Load(root)28+ if err != nil {29+ t.Fatalf("Load(%s): %v", root, err)30+ }31+ return s32+}33+34+func writeFile(t *testing.T, path, content string) {35+ t.Helper()36+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {37+ t.Fatal(err)38+ }39+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {40+ t.Fatal(err)41+ }42+}43+44+func changeIDs(cs []Change) []string {45+ var ids []string46+ for _, c := range cs {47+ ids = append(ids, c.ID)48+ }49+ return ids50+}51+52+func TestLoadFixtureCLIParity(t *testing.T) {53+ s := mustLoad(t, fixtureDir)54+55+ if !s.Exists {56+ t.Fatal("Exists = false, want true")57+ }58+ if s.Root != fixtureDir || s.Dir != filepath.Join(fixtureDir, "openspec") {59+ t.Errorf("Root, Dir = %q, %q", s.Root, s.Dir)60+ }61+ if want := filepath.Join(fixtureDir, "openspec", "config.yaml"); s.Config != want {62+ t.Errorf("Config = %q, want %q", s.Config, want)63+ }64+65+ // `openspec list --json`: 5 active changes (add-workspace-invites has only66+ // .openspec.yaml but is still a change), sorted by ID here.67+ wantChanges := []string{"add-csv-export-filters", "add-due-dates", "add-task-search", "add-workspace-invites", "harden-session-security"}68+ if got := changeIDs(s.Changes); !reflect.DeepEqual(got, wantChanges) {69+ t.Fatalf("Changes = %v, want %v", got, wantChanges)70+ }71+ wantTasks := map[string]TaskStats{72+ "add-csv-export-filters": {Done: 8, Total: 8},73+ "add-due-dates": {},74+ "add-task-search": {},75+ "add-workspace-invites": {},76+ "harden-session-security": {Done: 4, Total: 10},77+ }78+ for _, c := range s.Changes {79+ if c.Tasks != wantTasks[c.ID] {80+ t.Errorf("%s: Tasks = %+v, want %+v", c.ID, c.Tasks, wantTasks[c.ID])81+ }82+ if c.Archived || c.ArchivedOn != "" || c.Name != c.ID {83+ t.Errorf("%s: Archived=%v ArchivedOn=%q Name=%q for an active change", c.ID, c.Archived, c.ArchivedOn, c.Name)84+ }85+ if c.Schema != "spec-driven" {86+ t.Errorf("%s: Schema = %q, want spec-driven", c.ID, c.Schema)87+ }88+ if c.Dir != filepath.Join(s.Dir, "changes", c.ID) {89+ t.Errorf("%s: Dir = %q", c.ID, c.Dir)90+ }91+ }92+93+ // `openspec show <change> --json` deltaCount.94+ wantDeltas := map[string]DeltaStats{95+ "add-csv-export-filters": {Added: 1, Modified: 1},96+ "add-task-search": {Added: 2, Modified: 1},97+ "harden-session-security": {Added: 1, Modified: 1, Removed: 1, Renamed: 1},98+ }99+ for _, c := range s.Changes {100+ if c.Deltas != wantDeltas[c.ID] {101+ t.Errorf("%s: Deltas = %+v, want %+v", c.ID, c.Deltas, wantDeltas[c.ID])102+ }103+ }104+105+ // `openspec spec list --json` requirementCount, sorted by ID.106+ type specCount struct {107+ ID string108+ Requirements, Scenarios int109+ }110+ var gotSpecs []specCount111+ for _, sp := range s.Specs {112+ gotSpecs = append(gotSpecs, specCount{sp.ID, sp.Requirements, sp.Scenarios})113+ }114+ wantSpecs := []specCount{{"data-export", 3, 4}, {"notifications", 2, 4}, {"task-management", 4, 10}, {"user-auth", 4, 7}}115+ if !reflect.DeepEqual(gotSpecs, wantSpecs) {116+ t.Errorf("Specs = %+v, want %+v", gotSpecs, wantSpecs)117+ }118+119+ // Archive: sorted newest-first with the date prefix split off.120+ type arch struct{ ID, On, Name string }121+ var gotArch []arch122+ for _, c := range s.Archive {123+ gotArch = append(gotArch, arch{c.ID, c.ArchivedOn, c.Name})124+ if !c.Archived {125+ t.Errorf("%s: Archived = false", c.ID)126+ }127+ if c.Dir != filepath.Join(s.Dir, "changes", "archive", c.ID) {128+ t.Errorf("%s: Dir = %q", c.ID, c.Dir)129+ }130+ }131+ wantArch := []arch{132+ {"2026-09-23-add-email-notifications", "2026-09-23", "add-email-notifications"},133+ {"2026-08-19-add-user-auth", "2026-08-19", "add-user-auth"},134+ }135+ if !reflect.DeepEqual(gotArch, wantArch) {136+ t.Errorf("Archive = %+v, want %+v", gotArch, wantArch)137+ }138+ if got := s.Archive[0].Tasks; got != (TaskStats{Done: 7, Total: 7}) {139+ t.Errorf("add-email-notifications Tasks = %+v, want 7/7", got)140+ }141+ if got := s.Archive[1].Tasks; got != (TaskStats{Done: 10, Total: 10}) {142+ t.Errorf("add-user-auth Tasks = %+v, want 10/10", got)143+ }144+ if got := s.Archive[0].Deltas; got != (DeltaStats{Added: 2, Modified: 1}) {145+ t.Errorf("add-email-notifications Deltas = %+v", got)146+ }147+}148+149+func TestLoadFixtureArtifacts(t *testing.T) {150+ s := mustLoad(t, fixtureDir)151+152+ type art struct {153+ Kind ArtifactKind154+ Rel string155+ }156+ want := map[string][]art{157+ "add-csv-export-filters": {{KindProposal, "proposal.md"}, {KindTasks, "tasks.md"}, {KindDelta, "specs/data-export/spec.md"}},158+ "add-due-dates": {{KindProposal, "proposal.md"}, {KindOther, "README.md"}},159+ "add-task-search": {{KindProposal, "proposal.md"},160+ {KindDelta, "specs/search/spec.md"}, {KindDelta, "specs/task-management/spec.md"}},161+ "add-workspace-invites": nil, // .openspec.yaml is metadata, not an artifact162+ "harden-session-security": {{KindProposal, "proposal.md"}, {KindDesign, "design.md"},163+ {KindTasks, "tasks.md"}, {KindDelta, "specs/user-auth/spec.md"}},164+ "2026-08-19-add-user-auth": {{KindProposal, "proposal.md"}, {KindDesign, "design.md"},165+ {KindTasks, "tasks.md"}, {KindDelta, "specs/user-auth/spec.md"}},166+ "2026-09-23-add-email-notifications": {{KindProposal, "proposal.md"}, {KindTasks, "tasks.md"},167+ {KindDelta, "specs/notifications/spec.md"}, {KindDelta, "specs/task-management/spec.md"}},168+ }169+ for _, c := range append(append([]Change{}, s.Changes...), s.Archive...) {170+ var got []art171+ for _, a := range c.Artifacts {172+ got = append(got, art{a.Kind, a.Rel})173+ if a.Path != filepath.Join(c.Dir, filepath.FromSlash(a.Rel)) {174+ t.Errorf("%s/%s: Path = %q", c.ID, a.Rel, a.Path)175+ }176+ if a.Content == "" || a.ModTime.IsZero() {177+ t.Errorf("%s/%s: empty Content or zero ModTime", c.ID, a.Rel)178+ }179+ if a.ModTime.After(c.ModTime) {180+ t.Errorf("%s/%s: artifact ModTime %v after change ModTime %v", c.ID, a.Rel, a.ModTime, c.ModTime)181+ }182+ }183+ if !reflect.DeepEqual(got, want[c.ID]) {184+ t.Errorf("%s artifacts = %v, want %v", c.ID, got, want[c.ID])185+ }186+ }187+188+ harden, ok := s.FindChange("harden-session-security")189+ if !ok {190+ t.Fatal("FindChange(harden-session-security) not found")191+ }192+ if !harden.Has(KindDesign) || !harden.Has(KindDelta) {193+ t.Error("harden-session-security should have design and delta artifacts")194+ }195+ if d, _ := harden.Artifact(KindDelta); d.Label() != "Δ user-auth" || d.Capability() != "user-auth" {196+ t.Errorf("delta Label, Capability = %q, %q", d.Label(), d.Capability())197+ }198+ if p, _ := harden.Artifact(KindProposal); p.Label() != "proposal.md" {199+ t.Errorf("proposal Label = %q", p.Label())200+ }201+ csv, _ := s.FindChange("add-csv-export-filters")202+ if csv.Has(KindDesign) {203+ t.Error("add-csv-export-filters has no design.md")204+ }205+ if _, ok := csv.Artifact(KindDesign); ok {206+ t.Error("Artifact(KindDesign) ok = true for add-csv-export-filters")207+ }208+ if _, ok := s.FindChange("2026-08-19-add-user-auth"); !ok {209+ t.Error("FindChange should find archived changes by ID")210+ }211+ if _, ok := s.FindChange("nope"); ok {212+ t.Error("FindChange(nope) ok = true")213+ }214+215+ kinds := map[ArtifactKind]string{KindProposal: "proposal", KindDesign: "design", KindTasks: "tasks", KindDelta: "delta", KindOther: "other"}216+ for k, want := range kinds {217+ if k.String() != want {218+ t.Errorf("ArtifactKind(%d).String() = %q, want %q", k, k.String(), want)219+ }220+ }221+}222+223+func TestLoadFixtureSummariesAndSpecs(t *testing.T) {224+ s := mustLoad(t, fixtureDir)225+226+ wantSummary := map[string]string{227+ "add-csv-export-filters": "Project owners export to CSV mainly",228+ "add-due-dates": "Teams use Tally for release checklists",229+ "add-task-search": "Projects with more than a few hundred tasks", // H1 "Change:" title is skipped230+ "add-workspace-invites": "",231+ "harden-session-security": "A September penetration test flagged",232+ }233+ for _, c := range s.Changes {234+ want := wantSummary[c.ID]235+ if (want == "") != (c.Summary == "") || !strings.HasPrefix(c.Summary, want) {236+ t.Errorf("%s: Summary = %q, want prefix %q", c.ID, c.Summary, want)237+ }238+ }239+240+ notif, ok := s.FindSpec("notifications")241+ if !ok {242+ t.Fatal("FindSpec(notifications) not found")243+ }244+ if notif.Title != "notifications Specification" ||245+ notif.Purpose != "TBD - created by archiving change add-email-notifications. Update Purpose after archive." {246+ t.Errorf("notifications Title, Purpose = %q, %q", notif.Title, notif.Purpose)247+ }248+ export, _ := s.FindSpec("data-export")249+ if export.Title != "Data Export Specification" || !strings.HasPrefix(export.Purpose, "Lets project owners take their data") {250+ t.Errorf("data-export Title, Purpose = %q, %q", export.Title, export.Purpose)251+ }252+ if export.Path != filepath.Join(s.Dir, "specs", "data-export", "spec.md") || export.Content == "" || export.ModTime.IsZero() {253+ t.Errorf("data-export Path/Content/ModTime not populated: %q", export.Path)254+ }255+ if _, ok := s.FindSpec("search"); ok {256+ t.Error("FindSpec(search) ok = true; search only exists as a delta")257+ }258+}259+260+func TestLoadChangeModTimeIsNewestFile(t *testing.T) {261+ root := copyFixture(t)262+ before, _ := mustLoad(t, root).FindChange("add-task-search")263+ newest := before.ModTime.Add(time.Hour).Truncate(time.Second)264+ nested := filepath.Join(before.Dir, "specs", "search", "spec.md")265+ if err := os.Chtimes(nested, newest, newest); err != nil {266+ t.Fatal(err)267+ }268+ c, _ := mustLoad(t, root).FindChange("add-task-search")269+ if !c.ModTime.Equal(newest) {270+ t.Errorf("ModTime = %v, want nested delta mtime %v", c.ModTime, newest)271+ }272+}273+274+func TestLoadWithoutOpenspecDir(t *testing.T) {275+ root := t.TempDir()276+ s, err := Load(root)277+ if err != nil {278+ t.Fatalf("Load: %v", err)279+ }280+ if s.Exists || s.Root != root || s.Dir != filepath.Join(root, "openspec") {281+ t.Errorf("got Exists=%v Root=%q Dir=%q", s.Exists, s.Root, s.Dir)282+ }283+ if len(s.Changes)+len(s.Archive)+len(s.Specs) != 0 || s.Config != "" {284+ t.Errorf("expected an empty snapshot, got %+v", s)285+ }286+}287+288+func TestLoadOpenspecIsAFile(t *testing.T) {289+ root := t.TempDir()290+ writeFile(t, filepath.Join(root, "openspec"), "not a dir")291+ if _, err := Load(root); err == nil {292+ t.Error("Load succeeded when openspec is a file")293+ }294+}295+296+// A fresh clone has no empty dirs: openspec/ may lack specs/, changes/ and297+// changes/archive/.298+func TestLoadMissingSubdirs(t *testing.T) {299+ root := t.TempDir()300+ if err := os.Mkdir(filepath.Join(root, "openspec"), 0o755); err != nil {301+ t.Fatal(err)302+ }303+ s := mustLoad(t, root)304+ if !s.Exists || len(s.Changes)+len(s.Archive)+len(s.Specs) != 0 || s.Config != "" {305+ t.Errorf("got %+v", s)306+ }307+308+ // Legacy project.md is used when config.yaml is absent; changes without309+ // an archive dir still load.310+ writeFile(t, filepath.Join(root, "openspec", "project.md"), "# Project\n")311+ writeFile(t, filepath.Join(root, "openspec", "changes", "solo", "proposal.md"), "## Why\nSolo.\n")312+ s = mustLoad(t, root)313+ if s.Config != filepath.Join(root, "openspec", "project.md") {314+ t.Errorf("Config = %q, want project.md", s.Config)315+ }316+ if got := changeIDs(s.Changes); !reflect.DeepEqual(got, []string{"solo"}) || s.Changes[0].Schema != "" {317+ t.Errorf("Changes = %v (schema %q)", got, s.Changes[0].Schema)318+ }319+}320+321+func TestLoadSkipsDotDirsAndArchiveWithoutDatePrefix(t *testing.T) {322+ root := t.TempDir()323+ changes := filepath.Join(root, "openspec", "changes")324+ writeFile(t, filepath.Join(changes, ".scratch", "proposal.md"), "## Why\nx\n")325+ writeFile(t, filepath.Join(changes, "stray.md"), "not a change dir\n")326+ writeFile(t, filepath.Join(changes, "archive", "manually-moved", "proposal.md"), "## Why\nx\n")327+ s := mustLoad(t, root)328+ if len(s.Changes) != 0 {329+ t.Errorf("Changes = %v, want none (dot-dirs and files skipped)", changeIDs(s.Changes))330+ }331+ if len(s.Archive) != 1 || s.Archive[0].Name != "manually-moved" || s.Archive[0].ArchivedOn != "" {332+ t.Errorf("Archive = %+v", s.Archive)333+ }334+}335+336+// The CLI only reads deltas from specs/<capability>/spec.md (change-parser.js337+// parseDeltaSpecs iterates directories under specs/). Verified:338+// `openspec show <change> --json` reports deltaCount 0 for this layout.339+func TestLoadDeltaNeedsCapabilityDir(t *testing.T) {340+ t.Log("was BUG: load.go:217 classify treats \"specs/spec.md\" as KindDelta because it has the \"specs/\" prefix " +341+ "and \"/spec.md\" suffix; the artifact gets Capability \"spec.md\" (label \"Δ spec.md\") and its " +342+ "requirements are counted in Deltas, while the CLI ignores files without a capability dir")343+ root := t.TempDir()344+ writeFile(t, filepath.Join(root, "openspec", "changes", "loose", "specs", "spec.md"),345+ "## ADDED Requirements\n### Requirement: Loose\nThe system SHALL x.\n")346+ c := mustLoad(t, root).Changes[0]347+ if a := c.Artifacts[0]; a.Kind == KindDelta {348+ t.Errorf("specs/spec.md classified as delta (Capability %q)", a.Capability())349+ }350+ if c.Deltas.Total() != 0 {351+ t.Errorf("Deltas = %+v, want none", c.Deltas)352+ }353+}354+355+func TestFindRoot(t *testing.T) {356+ base, err := filepath.EvalSymlinks(t.TempDir()) // /var -> /private/var on macOS357+ if err != nil {358+ t.Fatal(err)359+ }360+ root := filepath.Join(base, "repo")361+ if err := os.CopyFS(root, os.DirFS(fixtureDir)); err != nil {362+ t.Fatal(err)363+ }364+ deep := filepath.Join(root, "src", "pkg", "deep")365+ if err := os.MkdirAll(deep, 0o755); err != nil {366+ t.Fatal(err)367+ }368+ outside := filepath.Join(base, "elsewhere", "nested")369+ if err := os.MkdirAll(outside, 0o755); err != nil {370+ t.Fatal(err)371+ }372+373+ tests := []struct {374+ name, start, want string375+ }{376+ {"root itself", root, root},377+ {"nested subdir", deep, root},378+ {"inside openspec/changes/<id>", filepath.Join(root, "openspec", "changes", "add-due-dates"), root},379+ {"relative path with dots", filepath.Join(deep, "..", ".."), root},380+ {"no openspec anywhere returns start", outside, outside},381+ }382+ for _, tt := range tests {383+ t.Run(tt.name, func(t *testing.T) {384+ got, err := FindRoot(tt.start)385+ if err != nil {386+ t.Fatalf("FindRoot: %v", err)387+ }388+ if got != tt.want {389+ t.Errorf("FindRoot(%q) = %q, want %q", tt.start, got, tt.want)390+ }391+ })392+ }393+394+ t.Run("relative to cwd", func(t *testing.T) {395+ t.Chdir(deep)396+ got, err := FindRoot(".")397+ if err != nil || got != root {398+ t.Errorf("FindRoot(.) = %q, %v; want %q", got, err, root)399+ }400+ })401+402+ t.Run("symlinked start resolves to real path", func(t *testing.T) {403+ link := filepath.Join(base, "link")404+ if err := os.Symlink(deep, link); err != nil {405+ t.Skip("symlinks unsupported:", err)406+ }407+ if got, err := FindRoot(link); err != nil || got != root {408+ t.Errorf("FindRoot(link) = %q, %v; want %q", got, err, root)409+ }410+ })411+}
M internal/spec/parse.go +36 -14
@@ -15,22 +15,31 @@ var (
1515 requirementRe = regexp.MustCompile(`(?i)^###\s*Requirement:`)1616 removedAltRe = regexp.MustCompile("(?i)^\\s*-\\s*`?###\\s*Requirement:")1717 scenarioRe = regexp.MustCompile(`^####\s+`)18- renameFromRe = regexp.MustCompile(`(?i)^\s*-?\s*FROM:`)19- fenceRe = regexp.MustCompile("^\\s*(```|~~~)")18+ renameFromRe = regexp.MustCompile("^\\s*-?\\s*FROM:\\s*`?###\\s*Requirement:")19+ renameToRe = regexp.MustCompile("^\\s*-?\\s*TO:\\s*`?###\\s*Requirement:")20+ fenceRe = regexp.MustCompile("^ {0,3}(`{3,}|~{3,})")21+ bulletRe = regexp.MustCompile(`^(?:[-*+]\s+|>\s*)+`)2022 )2123 2224 // lines yields the lines of a markdown document outside fenced code blocks.2325 func lines(src string, fn func(line string)) {2426 sc := bufio.NewScanner(strings.NewReader(src))2527 sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)26- inFence := false28+ fence := "" // opening marker of the current fenced block2729 for sc.Scan() {28- line := sc.Text()29- if fenceRe.MatchString(line) {30- inFence = !inFence30+ line := strings.TrimSuffix(sc.Text(), "\r")31+ if m := fenceRe.FindStringSubmatch(line); m != nil {32+ // CommonMark: a fence closes only with the same character and at33+ // least the same length, so ``` inside ~~~ stays masked.34+ switch {35+ case fence == "":36+ fence = m[1]37+ case m[1][0] == fence[0] && len(m[1]) >= len(fence) && strings.TrimSpace(line[len(m[0]):]) == "":38+ fence = ""39+ }3140 continue3241 }33- if !inFence {42+ if fence == "" {3443 fn(line)3544 }3645 }
@@ -56,6 +65,7 @@ func ParseTasks(src string) TaskStats {
5665 func ParseDelta(src string) DeltaStats {5766 var d DeltaStats5867 section := ""68+ pendingFrom := false5969 lines(src, func(line string) {6070 if m := sectionRe.FindStringSubmatch(line); m != nil {6171 section = strings.ToUpper(m[1])
@@ -79,8 +89,13 @@ func ParseDelta(src string) DeltaStats {
7989 d.Removed++8090 }8191 case "RENAMED":82- if renameFromRe.MatchString(line) {92+ // Count FROM/TO pairs, like the CLI; a lone FROM is not a rename.93+ switch {94+ case renameFromRe.MatchString(line):95+ pendingFrom = true96+ case renameToRe.MatchString(line) && pendingFrom:8397 d.Renamed++98+ pendingFrom = false8499 }85100 }86101 })
@@ -99,20 +114,24 @@ type SpecMeta struct {
99114 func ParseSpec(src string) SpecMeta {100115 var s SpecMeta101116 section := ""117+ inRequirement := false102118 lines(src, func(line string) {103119 switch {104120 case strings.HasPrefix(line, "# ") && s.Title == "":105121 s.Title = strings.TrimSpace(strings.TrimPrefix(line, "# "))106122 case strings.HasPrefix(line, "## "), strings.HasPrefix(line, "### ") && !requirementRe.MatchString(line):107123 section = strings.TrimSpace(strings.TrimLeft(line, "# "))124+ inRequirement = false125+ case !strings.EqualFold(section, "Requirements"):126+ // The CLI only counts requirements under "## Requirements".127+ if strings.EqualFold(section, "Purpose") && s.Purpose == "" {128+ s.Purpose = strings.TrimSpace(line)129+ }108130 case requirementRe.MatchString(line):109131 s.Requirements++110- case scenarioRe.MatchString(line):132+ inRequirement = true133+ case inRequirement && scenarioRe.MatchString(line):111134 s.Scenarios++112- case strings.EqualFold(section, "Purpose") && s.Purpose == "":113- if text := strings.TrimSpace(line); text != "" {114- s.Purpose = text115- }116135 }117136 })118137 return s
@@ -132,7 +151,7 @@ func ParseSummary(src string) string {
132151 if text == "" || strings.HasPrefix(text, "<!--") {133152 return134153 }135- text = strings.TrimSpace(strings.TrimLeft(text, "-*> "))154+ text = strings.TrimSpace(bulletRe.ReplaceAllString(text, ""))136155 if first == "" {137156 first = text138157 }
@@ -150,6 +169,9 @@ func ParseSummary(src string) string {
150169 func ParseSchema(src string) string {151170 for line := range strings.SplitSeq(src, "\n") {152171 if v, ok := strings.CutPrefix(strings.TrimSpace(line), "schema:"); ok {172+ if i := strings.Index(v, " #"); i >= 0 {173+ v = v[:i] // YAML inline comment174+ }153175 return strings.Trim(strings.TrimSpace(v), `"'`)154176 }155177 }
A internal/spec/parse_test.go +383 -0
@@ -0,0 +1,383 @@
1+package spec2+3+import (4+ "strings"5+ "testing"6+)7+8+// md joins lines with "\n" so table cases stay readable.9+func md(lines ...string) string { return strings.Join(lines, "\n") + "\n" }10+11+func TestParseTasks(t *testing.T) {12+ tests := []struct {13+ name string14+ src string15+ want TaskStats16+ }{17+ {"empty", "", TaskStats{}},18+ {"no checkboxes", md("## 1. Group", "", "Some prose."), TaskStats{}},19+ {"basic", md("## 1. Group", "", "- [ ] 1.1 a", "- [x] 1.2 b"), TaskStats{Done: 1, Total: 2}},20+ {"uppercase X is done", md("- [X] 1.1 a", "- [ ] 1.2 b"), TaskStats{Done: 1, Total: 2}},21+ {"star bullets count", md("* [x] a", "* [ ] b"), TaskStats{Done: 1, Total: 2}},22+ {"plus bullets ignored", md("+ [x] a", "- [ ] b"), TaskStats{Done: 0, Total: 1}},23+ {24+ "indented sub-checkboxes ignored",25+ md("- [ ] 2.2 parent", " - [x] sub done", " - [ ] sub open", "\t- [x] tab sub", "- [x] 2.3 next"),26+ TaskStats{Done: 1, Total: 2},27+ },28+ {29+ "checkboxes inside fenced code count (CLI does not mask fences)",30+ md("- [x] real", "```markdown", "- [ ] example", "- [x] example done", "```", "~~~", "* [ ] tilde", "~~~"),31+ TaskStats{Done: 2, Total: 4},32+ },33+ {"other marks are not tasks", md("- [~] partial", "- [-] skipped", "- [ ] two spaces", "- [ ] real"), TaskStats{Total: 1}},34+ {"no space after bullet", md("-[ ] a", "*[x] b"), TaskStats{}},35+ {"several spaces after bullet", md("- [x] a", "*\t[ ] b"), TaskStats{Done: 1, Total: 2}},36+ {"no description still counts", md("- [ ]", "- [x]"), TaskStats{Done: 1, Total: 2}},37+ {"no space after bracket", md("- [x]1.1 squashed"), TaskStats{Done: 1, Total: 1}},38+ {"numbered list ignored", md("1. [x] a", "2. [ ] b"), TaskStats{}},39+ {"CRLF line endings", "- [x] a\r\n- [ ] b\r\n- [X] c\r\n", TaskStats{Done: 2, Total: 3}},40+ {"no trailing newline", "- [x] a\n- [ ] b", TaskStats{Done: 1, Total: 2}},41+ }42+ for _, tt := range tests {43+ t.Run(tt.name, func(t *testing.T) {44+ if got := ParseTasks(tt.src); got != tt.want {45+ t.Errorf("ParseTasks() = %+v, want %+v", got, tt.want)46+ }47+ })48+ }49+}50+51+func TestTaskStatsComplete(t *testing.T) {52+ tests := []struct {53+ in TaskStats54+ want bool55+ }{56+ {TaskStats{}, false},57+ {TaskStats{Done: 0, Total: 3}, false},58+ {TaskStats{Done: 2, Total: 3}, false},59+ {TaskStats{Done: 3, Total: 3}, true},60+ }61+ for _, tt := range tests {62+ if got := tt.in.Complete(); got != tt.want {63+ t.Errorf("%+v.Complete() = %v, want %v", tt.in, got, tt.want)64+ }65+ }66+}67+68+func TestParseDelta(t *testing.T) {69+ req := func(name string) []string {70+ return []string{"### Requirement: " + name, "The system SHALL " + name + ".", "", "#### Scenario: s", "- **WHEN** a", "- **THEN** b", ""}71+ }72+ join := func(parts ...[]string) string {73+ var all []string74+ for _, p := range parts {75+ all = append(all, p...)76+ }77+ return md(all...)78+ }79+ h := func(s ...string) []string { return s }80+81+ tests := []struct {82+ name string83+ src string84+ want DeltaStats85+ }{86+ {"empty", "", DeltaStats{}},87+ {"added", join(h("## ADDED Requirements", ""), req("A"), req("B")), DeltaStats{Added: 2}},88+ {"modified", join(h("## MODIFIED Requirements", ""), req("A")), DeltaStats{Modified: 1}},89+ {90+ "removed header form",91+ md("## REMOVED Requirements", "", "### Requirement: Remember me", "**Reason**: risk", "**Migration**: none"),92+ DeltaStats{Removed: 1},93+ },94+ {95+ "removed alt bullet form",96+ md("## REMOVED Requirements", "", "- `### Requirement: Remember me`", "- `### Requirement: Legacy tokens`"),97+ DeltaStats{Removed: 2},98+ },99+ {100+ "removed alt bullet form without backticks",101+ md("## REMOVED Requirements", "- ### Requirement: Remember me"),102+ DeltaStats{Removed: 1},103+ },104+ {105+ "renamed with leading dash",106+ md("## RENAMED Requirements", "", "- FROM: `### Requirement: Password login`", "- TO: `### Requirement: Password authentication`"),107+ DeltaStats{Renamed: 1},108+ },109+ {110+ "renamed without leading dash",111+ md("## RENAMED Requirements", "", "FROM: `### Requirement: Password login`", "TO: `### Requirement: Password authentication`"),112+ DeltaStats{Renamed: 1},113+ },114+ {115+ "renamed indented, no backticks",116+ md("## RENAMED Requirements", " - FROM: ### Requirement: A", " - TO: ### Requirement: B"),117+ DeltaStats{Renamed: 1},118+ },119+ {120+ "two rename pairs",121+ md("## RENAMED Requirements", "- FROM: `### Requirement: A`", "- TO: `### Requirement: B`", "", "- FROM: `### Requirement: C`", "- TO: `### Requirement: D`"),122+ DeltaStats{Renamed: 2},123+ },124+ {125+ "case-insensitive section headers",126+ join(h("## added requirements"), req("A"), h("## Modified Requirements"), req("B"), h("## removed REQUIREMENTS", "### Requirement: C", "## Renamed Requirements", "- FROM: `### Requirement: D`", "- TO: `### Requirement: E`")),127+ DeltaStats{Added: 1, Modified: 1, Removed: 1, Renamed: 1},128+ },129+ {"section header trailing whitespace", join(h("## ADDED Requirements "), req("A")), DeltaStats{Added: 1}},130+ {"case-insensitive requirement header", md("## ADDED Requirements", "### requirement: lower", "### REQUIREMENT: upper"), DeltaStats{Added: 2}},131+ {"no space after ###", md("## ADDED Requirements", "###Requirement: tight"), DeltaStats{Added: 1}},132+ {"H4 requirement is not a requirement", md("## ADDED Requirements", "#### Requirement: too deep"), DeltaStats{}},133+ {"bullet form does not count under ADDED", md("## ADDED Requirements", "- `### Requirement: A`"), DeltaStats{}},134+ {"requirement header under RENAMED is not a rename", md("## RENAMED Requirements", "### Requirement: A"), DeltaStats{}},135+ {136+ "requirements before any delta section are ignored",137+ join(h("# Delta for auth", ""), req("Preamble"), h("## ADDED Requirements"), req("A")),138+ DeltaStats{Added: 1},139+ },140+ {141+ "unrelated ## section ends the delta section",142+ join(h("## ADDED Requirements"), req("A"), h("## Notes"), req("Stray")),143+ DeltaStats{Added: 1},144+ },145+ {146+ "requirement headers inside fenced code do not count",147+ join(h("## ADDED Requirements"), req("A"), h("```markdown", "### Requirement: Example only", "## REMOVED Requirements", "### Requirement: Also example", "```", "~~~", "### Requirement: Tilde example", "~~~")),148+ DeltaStats{Added: 1},149+ },150+ {151+ "section header inside fence does not switch section",152+ md("## MODIFIED Requirements", "```", "## ADDED Requirements", "```", "### Requirement: A"),153+ DeltaStats{Modified: 1},154+ },155+ {156+ "all four operations",157+ join(h("## ADDED Requirements"), req("A"), h("## MODIFIED Requirements"), req("B"), req("C"),158+ h("## REMOVED Requirements", "### Requirement: D", "- `### Requirement: E`", "## RENAMED Requirements", "- FROM: `### Requirement: F`", "- TO: `### Requirement: G`")),159+ DeltaStats{Added: 1, Modified: 2, Removed: 2, Renamed: 1},160+ },161+ {"CRLF line endings", "## ADDED Requirements\r\n### Requirement: A\r\nThe system SHALL a.\r\n", DeltaStats{Added: 1}},162+ }163+ for _, tt := range tests {164+ t.Run(tt.name, func(t *testing.T) {165+ got := ParseDelta(tt.src)166+ if got != tt.want {167+ t.Errorf("ParseDelta() = %+v, want %+v", got, tt.want)168+ }169+ if got.Total() != tt.want.Total() {170+ t.Errorf("Total() = %d, want %d", got.Total(), tt.want.Total())171+ }172+ })173+ }174+}175+176+// The OpenSpec CLI (requirement-blocks.js parseRenamedPairs and177+// change-parser.js parseRenames) only counts a rename when a178+// "FROM: `### Requirement: X`" line is followed by a matching179+// "TO: `### Requirement: Y`" line, and both regexes are case-sensitive and180+// require the "### Requirement:" part. Verified: `openspec show <change>181+// --json` reports deltaCount 0 for this input.182+func TestParseDeltaRenamedNeedsFromToPair(t *testing.T) {183+ t.Log("was BUG: parse.go:18 renameFromRe `(?i)^\\s*-?\\s*FROM:` counts every FROM-ish line under RENAMED " +184+ "(orphan FROM without TO, prose like \"- From: ...\"), so ParseDelta reports Renamed=2 where the CLI reports 0")185+ src := md("## RENAMED Requirements", "",186+ "- FROM: `### Requirement: Password login`",187+ "- From: the old wording was ambiguous")188+ if got := ParseDelta(src); got.Renamed != 0 {189+ t.Errorf("Renamed = %d, want 0 (no FROM/TO pair)", got.Renamed)190+ }191+}192+193+// CommonMark (and the CLI's MarkdownParser.buildCodeFenceMask) only closes a194+// fence with the same marker character and at least the same length, so a195+// ``` block nested inside a ~~~ (or ````) block stays masked.196+func TestParseNestedFenceStaysMasked(t *testing.T) {197+ t.Log("was BUG: parse.go:29 lines() toggles inFence on ANY ``` or ~~~ line, so an inner ``` inside a ~~~ " +198+ "(or ````) block un-masks the example; ParseSpec/ParseDelta count the fenced \"### Requirement:\" " +199+ "(spec: 2 vs CLI `spec list` requirementCount 1)")200+ example := []string{"~~~markdown", "```", "### Requirement: Fake inside nested fence", "#### Scenario: fake", "```", "~~~"}201+ spec := md(append([]string{"# S", "## Purpose", "p", "## Requirements", "### Requirement: Real", "The system SHALL x.", "#### Scenario: s"}, example...)...)202+ if got := ParseSpec(spec); got.Requirements != 1 || got.Scenarios != 1 {203+ t.Errorf("ParseSpec() = %+v, want 1 requirement and 1 scenario", got)204+ }205+ delta := md(append([]string{"## ADDED Requirements", "### Requirement: Real"}, example...)...)206+ if got := ParseDelta(delta); got.Added != 1 {207+ t.Errorf("ParseDelta().Added = %d, want 1", got.Added)208+ }209+ four := md("## ADDED Requirements", "### Requirement: Real", "````md", "```", "### Requirement: Fake", "```", "````")210+ if got := ParseDelta(four); got.Added != 1 {211+ t.Errorf("ParseDelta(```` fence).Added = %d, want 1", got.Added)212+ }213+}214+215+func TestParseSpec(t *testing.T) {216+ tests := []struct {217+ name string218+ src string219+ want SpecMeta220+ }{221+ {"empty", "", SpecMeta{}},222+ {223+ "standard layout",224+ md("# Data Export Specification", "", "## Purpose", "", "Lets owners export.", "Second line.", "",225+ "## Requirements", "", "### Requirement: CSV export", "The system SHALL export CSV.", "",226+ "#### Scenario: Export", "- **WHEN** x", "- **THEN** y", "", "#### Scenario: Escape", "- **WHEN** x", "",227+ "### Requirement: JSON export", "The system SHALL export JSON.", "", "#### Scenario: JSON", "- **WHEN** x"),228+ SpecMeta{Title: "Data Export Specification", Purpose: "Lets owners export.", Requirements: 2, Scenarios: 3},229+ },230+ {231+ "archive skeleton without blank lines",232+ md("# notifications Specification", "", "## Purpose", "TBD - created by archiving change x. Update Purpose after archive.",233+ "## Requirements", "### Requirement: A", "The system SHALL a.", "", "#### Scenario: s", "- **WHEN** x"),234+ SpecMeta{Title: "notifications Specification", Purpose: "TBD - created by archiving change x. Update Purpose after archive.", Requirements: 1, Scenarios: 1},235+ },236+ {237+ "### Purpose works like ## Purpose",238+ md("# T", "## Overview", "### Purpose", "Nested purpose.", "## Requirements", "### Requirement: A"),239+ SpecMeta{Title: "T", Purpose: "Nested purpose.", Requirements: 1},240+ },241+ {242+ "purpose header is case-insensitive",243+ md("# T", "## PURPOSE", "", " Indented purpose text. "),244+ SpecMeta{Title: "T", Purpose: "Indented purpose text."},245+ },246+ {"no purpose section", md("# T", "## Requirements", "### Requirement: A"), SpecMeta{Title: "T", Requirements: 1}},247+ {248+ "purpose not taken from other sections",249+ md("# T", "## Background", "Not the purpose.", "## Purpose", "", "Real purpose."),250+ SpecMeta{Title: "T", Purpose: "Real purpose."},251+ },252+ {"empty purpose section", md("# T", "## Purpose", "", "## Requirements", "Body text."), SpecMeta{Title: "T"}},253+ {"only first H1 is the title", md("# First", "# Second"), SpecMeta{Title: "First"}},254+ {"no title", md("## Purpose", "p"), SpecMeta{Purpose: "p"}},255+ {256+ "requirement header is case-insensitive and needs ###",257+ md("## Requirements", "### requirement: lower", "###Requirement: tight", "#### Requirement: too deep", "## Requirement: too shallow"),258+ SpecMeta{Requirements: 2, Scenarios: 1},259+ },260+ {261+ "H5 is not a scenario",262+ md("## Requirements", "### Requirement: A", "#### Scenario: s", "##### Detail"),263+ SpecMeta{Requirements: 1, Scenarios: 1},264+ },265+ {266+ "requirement and scenario headers inside fenced code do not count",267+ md("# T", "## Purpose", "p", "## Requirements", "### Requirement: Real", "The system SHALL x.", "#### Scenario: s",268+ "```markdown", "### Requirement: Example", "#### Scenario: Example", "```",269+ "~~~", "### Requirement: Tilde example", "~~~"),270+ SpecMeta{Title: "T", Purpose: "p", Requirements: 1, Scenarios: 1},271+ },272+ {273+ "heading inside fence does not become the title or purpose",274+ md("```", "# Not a title", "## Purpose", "```", "# Title", "## Purpose", "Real."),275+ SpecMeta{Title: "Title", Purpose: "Real."},276+ },277+ {278+ "CRLF line endings",279+ "# T\r\n## Purpose\r\nP.\r\n## Requirements\r\n### Requirement: A\r\n#### Scenario: s\r\n",280+ SpecMeta{Title: "T", Purpose: "P.", Requirements: 1, Scenarios: 1},281+ },282+ }283+ for _, tt := range tests {284+ t.Run(tt.name, func(t *testing.T) {285+ if got := ParseSpec(tt.src); got != tt.want {286+ t.Errorf("ParseSpec() = %+v, want %+v", got, tt.want)287+ }288+ })289+ }290+}291+292+// The CLI counts requirements as the children of the "Requirements" section293+// only (markdown-parser.js parseRequirements); a requirement header under a294+// later "## Notes" section is a validation ERROR and is not counted.295+// Verified: `openspec spec list --json` reports requirementCount 1 here.296+func TestParseSpecIgnoresRequirementsOutsideRequirementsSection(t *testing.T) {297+ t.Log("was BUG: parse.go:108 ParseSpec counts every \"### Requirement:\" header in the file regardless of section, " +298+ "so a stray header under \"## Notes\" gives 2 where `openspec spec list` reports 1")299+ src := md("# T", "## Purpose", "p", "## Requirements", "### Requirement: Real", "The system SHALL x.",300+ "## Notes", "### Requirement: Stray", "The system SHALL not count this.")301+ if got := ParseSpec(src); got.Requirements != 1 {302+ t.Errorf("Requirements = %d, want 1", got.Requirements)303+ }304+}305+306+func TestParseSummary(t *testing.T) {307+ tests := []struct {308+ name string309+ src string310+ want string311+ }{312+ {"empty", "", ""},313+ {"only headings", md("## Why", "## What Changes"), ""},314+ {"line under Why", md("## Why", "", "Because sessions last too long.", "", "## What Changes", "- Shorten"), "Because sessions last too long."},315+ {316+ "prefers Why over earlier prose",317+ md("# Change: Full-text task search", "", "Intro paragraph before why.", "", "## Why", "", "The real reason."),318+ "The real reason.",319+ },320+ {"Why later in the document", md("## What Changes", "- Add search", "", "## Why", "Reason."), "Reason."},321+ {"Why header is case-insensitive and any level", md("### WHY", "Reason."), "Reason."},322+ {"falls back to first prose line", md("## What Changes", "", "- Add search endpoint", "- Rank results"), "Add search endpoint"},323+ {"empty Why falls back", md("## Why", "", "## What Changes", "- Add search"), "Add search"},324+ {"only first line of Why paragraph", md("## Why", "First line.", "Second line."), "First line."},325+ {"skips HTML comments", md("## Why", "<!-- Explain the motivation -->", "Reason."), "Reason."},326+ {"strips bullet and quote markers", md("## Why", "> - quoted bullet"), "quoted bullet"},327+ {"strips star bullet", md("## Why", "* starred"), "starred"},328+ {"trims indentation", md("## Why", " indented reason "), "indented reason"},329+ {"ignores fenced code", md("## Why", "```", "code line", "```", "Prose."), "Prose."},330+ {"CRLF line endings", "## Why\r\n\r\nReason.\r\n", "Reason."},331+ }332+ for _, tt := range tests {333+ t.Run(tt.name, func(t *testing.T) {334+ if got := ParseSummary(tt.src); got != tt.want {335+ t.Errorf("ParseSummary() = %q, want %q", got, tt.want)336+ }337+ })338+ }339+}340+341+func TestParseSummaryKeepsLeadingBold(t *testing.T) {342+ t.Log("was BUG: parse.go:135 strings.TrimLeft(text, \"-*> \") strips a cutset, not a bullet prefix, so a Why line " +343+ "starting with bold markdown loses its opening ** and becomes \"Security:** sessions last too long.\"")344+ src := md("## Why", "", "**Security:** sessions last too long.")345+ if got, want := ParseSummary(src), "**Security:** sessions last too long."; got != want {346+ t.Errorf("ParseSummary() = %q, want %q", got, want)347+ }348+}349+350+func TestParseSchema(t *testing.T) {351+ tests := []struct {352+ name string353+ src string354+ want string355+ }{356+ {"empty", "", ""},357+ {"standard", md("schema: spec-driven", "created: 2026-10-13"), "spec-driven"},358+ {"not first line", md("created: 2026-10-13", "schema: workspace-planning"), "workspace-planning"},359+ {"double quoted", md(`schema: "spec-driven"`), "spec-driven"},360+ {"single quoted", md(`schema: 'spec-driven'`), "spec-driven"},361+ {"no space after colon", md("schema:custom"), "custom"},362+ {"missing", md("created: 2026-10-13"), ""},363+ {"commented out", md("# schema: spec-driven"), ""},364+ {"different key with same prefix", md("schemas: x"), ""},365+ {"first wins", md("schema: a", "schema: b"), "a"},366+ {"CRLF", "schema: spec-driven\r\ncreated: 2026-10-13\r\n", "spec-driven"},367+ }368+ for _, tt := range tests {369+ t.Run(tt.name, func(t *testing.T) {370+ if got := ParseSchema(tt.src); got != tt.want {371+ t.Errorf("ParseSchema() = %q, want %q", got, tt.want)372+ }373+ })374+ }375+}376+377+func TestParseSchemaStripsInlineComment(t *testing.T) {378+ t.Log("was BUG: parse.go:153 ParseSchema does not strip YAML inline comments, so " +379+ "\"schema: spec-driven # default\" yields \"spec-driven # default\" (the CLI's YAML parser yields \"spec-driven\")")380+ if got := ParseSchema("schema: spec-driven # default\n"); got != "spec-driven" {381+ t.Errorf("ParseSchema() = %q, want %q", got, "spec-driven")382+ }383+}
M internal/ui/mainpane.go +0 -4
M internal/ui/render.go +6 -2
M main.go +12 -3