1package spec23import (4 "errors"5 "io/fs"6 "os"7 "path/filepath"8 "regexp"9 "sort"10 "strings"11 "time"12)1314// DirName is the name of the OpenSpec directory at a repository root.15const DirName = "openspec"1617var archivePrefixRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-(.+)$`)1819// DisplayRoot returns root as reached from start without resolving symlinks20// (e.g. /tmp/repo rather than /private/tmp/repo), for showing to the user.21// It falls back to root when no unresolved ancestor of start resolves to it.22func DisplayRoot(start, root string) string {23 abs, err := filepath.Abs(start)24 if err != nil {25 return root26 }27 for dir := abs; ; dir = filepath.Dir(dir) {28 if real, err := filepath.EvalSymlinks(dir); err == nil && real == root {29 return dir30 }31 if filepath.Dir(dir) == dir {32 return root33 }34 }35}3637// ArchiveName strips the YYYY-MM-DD- prefix from an archived change's ID.38func ArchiveName(id string) string {39 if m := archivePrefixRe.FindStringSubmatch(id); m != nil {40 return m[2]41 }42 return id43}4445// FindRoot walks up from start looking for a directory that contains46// openspec/. It returns start itself when no ancestor has one. The result has47// symlinks resolved so it matches the paths git reports; DisplayRoot gives48// the same directory as the user would write it.49func FindRoot(start string) (string, error) {50 abs, err := filepath.Abs(start)51 if err != nil {52 return "", err53 }54 // Resolve first so the walk goes up the real tree, not a symlink's55 // parents, and so paths match what git reports (/tmp on macOS).56 if real, err := filepath.EvalSymlinks(abs); err == nil {57 abs = real58 }59 for dir := abs; ; {60 if fi, err := os.Stat(filepath.Join(dir, DirName)); err == nil && fi.IsDir() {61 return dir, nil62 }63 parent := filepath.Dir(dir)64 if parent == dir {65 return abs, nil66 }67 dir = parent68 }69}7071// Load reads the OpenSpec tree rooted at root/openspec.72func Load(root string) (*Snapshot, error) {73 s := &Snapshot{Root: root, Dir: filepath.Join(root, DirName)}74 fi, err := os.Stat(s.Dir)75 if errors.Is(err, fs.ErrNotExist) {76 return s, nil77 }78 if err != nil {79 return nil, err80 }81 if !fi.IsDir() {82 return nil, errors.New(s.Dir + " is not a directory")83 }84 s.Exists = true8586 for _, name := range []string{"config.yaml", "project.md"} {87 if p := filepath.Join(s.Dir, name); fileExists(p) {88 s.Config = p89 break90 }91 }9293 s.Specs, err = loadSpecs(filepath.Join(s.Dir, "specs"))94 if err != nil {95 return nil, err96 }9798 changesDir := filepath.Join(s.Dir, "changes")99 s.Changes, err = loadChanges(changesDir, false)100 if err != nil {101 return nil, err102 }103 s.Archive, err = loadChanges(filepath.Join(changesDir, "archive"), true)104 if err != nil {105 return nil, err106 }107 // Newest first: by archive date, then by last edit within a day.108 sort.Slice(s.Archive, func(i, j int) bool {109 a, b := s.Archive[i], s.Archive[j]110 if a.ArchivedOn != b.ArchivedOn {111 return a.ArchivedOn > b.ArchivedOn112 }113 if !a.ModTime.Equal(b.ModTime) {114 return a.ModTime.After(b.ModTime)115 }116 return a.ID > b.ID117 })118 return s, nil119}120121func loadSpecs(dir string) ([]Spec, error) {122 var specs []Spec123 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {124 if err != nil {125 return skip(d) // unreadable or vanished mid-walk; show the rest126 }127 if !d.Type().IsRegular() || d.Name() != "spec.md" {128 return nil129 }130 rel, err := filepath.Rel(dir, filepath.Dir(path))131 if err != nil {132 return err133 }134 src, mod, err := readFile(path)135 if err != nil {136 return nil // vanished mid-walk; the next scan will catch up137 }138 meta := ParseSpec(src)139 specs = append(specs, Spec{140 ID: filepath.ToSlash(rel),141 Path: path,142 Title: meta.Title,143 Purpose: meta.Purpose,144 Requirements: meta.Requirements,145 Scenarios: meta.Scenarios,146 ModTime: mod,147 Content: src,148 })149 return nil150 })151 sort.Slice(specs, func(i, j int) bool { return specs[i].ID < specs[j].ID })152 return specs, err153}154155func loadChanges(dir string, archived bool) ([]Change, error) {156 entries, err := os.ReadDir(dir)157 if errors.Is(err, fs.ErrNotExist) {158 return nil, nil159 }160 if err != nil {161 return nil, err162 }163 var changes []Change164 for _, e := range entries {165 if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {166 continue167 }168 if !archived && e.Name() == "archive" {169 continue170 }171 c, err := loadChange(filepath.Join(dir, e.Name()), archived)172 if err != nil {173 return nil, err174 }175 changes = append(changes, c)176 }177 sort.Slice(changes, func(i, j int) bool { return changes[i].ID < changes[j].ID })178 return changes, nil179}180181func loadChange(dir string, archived bool) (Change, error) {182 c := Change{ID: filepath.Base(dir), Dir: dir, Archived: archived}183 c.Name = c.ID184 if archived {185 if m := archivePrefixRe.FindStringSubmatch(c.ID); m != nil {186 c.ArchivedOn, c.Name = m[1], m[2]187 }188 }189 if fi, err := os.Stat(dir); err == nil {190 c.ModTime = fi.ModTime()191 }192193 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {194 if err != nil {195 return skip(d) // unreadable or vanished mid-walk; show the rest196 }197 if !d.Type().IsRegular() {198 return nil // directories, and FIFOs/sockets that would block a read199 }200 rel, err := filepath.Rel(dir, path)201 if err != nil {202 return nil203 }204 rel = filepath.ToSlash(rel)205 if rel != ".openspec.yaml" && !strings.HasSuffix(rel, ".md") {206 if fi, err := d.Info(); err == nil && fi.ModTime().After(c.ModTime) {207 c.ModTime = fi.ModTime()208 }209 return nil210 }211 src, mod, err := readFile(path)212 if err != nil {213 return nil214 }215 if mod.After(c.ModTime) {216 c.ModTime = mod217 }218 if rel == ".openspec.yaml" {219 c.Schema = ParseSchema(src)220 return nil221 }222 a := Artifact{Kind: classify(rel), Rel: rel, Path: path, ModTime: mod, Content: src}223 switch a.Kind {224 case KindProposal:225 c.Summary = ParseSummary(src)226 case KindTasks:227 c.Tasks = ParseTasks(src)228 case KindDelta:229 c.Deltas = c.Deltas.add(ParseDelta(src))230 }231 c.Artifacts = append(c.Artifacts, a)232 return nil233 })234 sort.SliceStable(c.Artifacts, func(i, j int) bool {235 a, b := c.Artifacts[i], c.Artifacts[j]236 if a.Kind != b.Kind {237 return a.Kind < b.Kind238 }239 return a.Rel < b.Rel240 })241 return c, err242}243244// skip tells WalkDir to continue past an entry it could not read.245func skip(d fs.DirEntry) error {246 if d != nil && d.IsDir() {247 return fs.SkipDir248 }249 return nil250}251252func classify(rel string) ArtifactKind {253 switch rel {254 case "proposal.md":255 return KindProposal256 case "design.md":257 return KindDesign258 case "tasks.md":259 return KindTasks260 }261 // The CLI reads deltas only from specs/<capability>/spec.md.262 if dir, ok := strings.CutPrefix(rel, "specs/"); ok && strings.Count(dir, "/") == 1 && strings.HasSuffix(dir, "/spec.md") {263 return KindDelta264 }265 return KindOther266}267268func readFile(path string) (string, time.Time, error) {269 fi, err := os.Stat(path)270 if err != nil {271 return "", time.Time{}, err272 }273 b, err := os.ReadFile(path)274 if err != nil {275 return "", time.Time{}, err276 }277 return string(b), fi.ModTime(), nil278}279280func fileExists(path string) bool {281 fi, err := os.Stat(path)282 return err == nil && !fi.IsDir()283}