Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 123 additions & 33 deletions internal/cli/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"strings"
"time"

"github.com/protonspy/csdd/internal/frontmatter"
"github.com/protonspy/csdd/internal/paths"
"github.com/protonspy/csdd/internal/render"
"github.com/protonspy/csdd/internal/session"
Expand All @@ -28,11 +29,72 @@ type SpecJSON struct {
FeatureName string `json:"feature_name"`
Language string `json:"language"`
Phase string `json:"phase"`
DevelopmentFlow string `json:"development_flow,omitempty"`
Approvals map[string]ApprovalFlag `json:"approvals"`
ReadyForImplementation bool `json:"ready_for_implementation"`
CreatedAt string `json:"created_at"`
}

// defaultDevelopmentFlow is the flow assumed when none is selected and steering
// declares no default. It keeps csdd's test-first posture as the default.
const defaultDevelopmentFlow = "tdd"

// developmentFlows is the closed set of selectable development flows:
// - unit: tests written after the code (no RED-first ritual)
// - tdd: test-first RED→GREEN (the default)
// - tdd-e2e: TDD plus end-to-end coverage of golden and error flows
var developmentFlows = []string{"unit", "tdd", "tdd-e2e"}

// validDevelopmentFlow reports whether f is one of the selectable flows.
func validDevelopmentFlow(f string) bool {
for _, v := range developmentFlows {
if v == f {
return true
}
}
return false
}

// effectiveFlow coerces a stored (possibly empty, e.g. legacy spec.json) flow to
// the value callers should act on. Absent ⇒ defaultDevelopmentFlow.
func effectiveFlow(f string) string {
if f == "" {
return defaultDevelopmentFlow
}
return f
}

// resolveDefaultFlow returns the workspace default development flow declared in
// steering frontmatter (key default_development_flow). Steering files are scanned
// in name order; the first valid declared value wins. Absent or invalid ⇒
// defaultDevelopmentFlow (an invalid value is separately flagged by the steering
// validator, so init stays robust rather than writing a bad flow).
func resolveDefaultFlow(root string) string {
dir := paths.Steering(root)
entries, err := os.ReadDir(dir)
if err != nil {
return defaultDevelopmentFlow
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, n := range names {
data, err := os.ReadFile(filepath.Join(dir, n))
if err != nil {
continue
}
fm := frontmatter.Parse(string(data))
if v := fm.AsString("default_development_flow", ""); validDevelopmentFlow(v) {
return v
}
}
return defaultDevelopmentFlow
}

// ApprovalFlag tracks generation + approval state for a phase.
type ApprovalFlag struct {
Generated bool `json:"generated"`
Expand Down Expand Up @@ -72,57 +134,84 @@ func runSpec(args []string, templates embed.FS) int {
}
}

func specInit(args []string, templates embed.FS) int {
fs := flag.NewFlagSet("spec init", flag.ContinueOnError)
var root, language string
addRoot(fs, &root)
fs.StringVar(&language, "language", "en", "Spec language (default: en).")
positionals, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
}
if len(positionals) < 1 {
render.Err("usage: " + prog() + " spec init FEATURE")
return 1
// SpecInitOptions is the headless input shared by the CLI action and the TUI
// wizard. Flow "" resolves the steering default, then defaultDevelopmentFlow.
type SpecInitOptions struct {
Root string
Feature string
Language string
Flow string
}

// SpecInit creates specs/<feature>/spec.json with the resolved development flow.
// It is the single source of truth for spec creation; the CLI action and the TUI
// wizard both fill SpecInitOptions and call it. An explicit invalid Flow is
// rejected before any write (the caller maps the error to exit 1).
func SpecInit(templates embed.FS, opts SpecInitOptions) error {
if opts.Flow != "" && !validDevelopmentFlow(opts.Flow) {
return fmt.Errorf("invalid development flow %q: must be one of %s", opts.Flow, strings.Join(developmentFlows, "|"))
}
r, err := workspace.Resolve(root)
r, err := workspace.Resolve(opts.Root)
if err != nil {
render.Err(err.Error())
return 1
return err
}
if _, err := workspace.SpecsDir(r); err != nil {
render.Err(err.Error())
return 1
return err
}
feature := positionals[0]
if err := workspace.KebabCheck(feature, "feature"); err != nil {
render.Err(err.Error())
return 1
if err := workspace.KebabCheck(opts.Feature, "feature"); err != nil {
return err
}
target := filepath.Join(paths.Specs(r), feature)
target := filepath.Join(paths.Specs(r), opts.Feature)
if pathExists(target) {
render.Err("spec already exists: " + workspace.Relative(r, target))
return 1
return errors.New("spec already exists: " + workspace.Relative(r, target))
}
flow := opts.Flow
if flow == "" {
flow = resolveDefaultFlow(r)
}
language := opts.Language
if language == "" {
language = "en"
}
if err := mkdirAll(target); err != nil {
render.Err(err.Error())
return 1
return err
}
content, err := templater.Render(templates, "templates/spec/spec.json.tmpl", map[string]string{
"Feature": feature,
"Language": language,
"CreatedAt": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
"Feature": opts.Feature,
"Language": language,
"DevelopmentFlow": flow,
"CreatedAt": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
if err != nil {
render.Err(err.Error())
return 1
return err
}
if err := workspace.WriteFile(filepath.Join(target, "spec.json"), content, false); err != nil {
return err
}
render.OK("created " + workspace.Relative(r, target) + "/ (flow: " + flow + ")")
render.Info(fmt.Sprintf("next: `%s spec generate <feature> --artifact requirements`", prog()))
return nil
}

func specInit(args []string, templates embed.FS) int {
fs := flag.NewFlagSet("spec init", flag.ContinueOnError)
var opts SpecInitOptions
addRoot(fs, &opts.Root)
fs.StringVar(&opts.Language, "language", "en", "Spec language (default: en).")
fs.StringVar(&opts.Flow, "flow", "", "Development flow: unit|tdd|tdd-e2e (default: steering default, else tdd).")
positionals, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
}
if len(positionals) < 1 {
render.Err("usage: " + prog() + " spec init FEATURE")
return 1
}
opts.Feature = positionals[0]
if err := SpecInit(templates, opts); err != nil {
render.Err(err.Error())
return 1
}
render.OK("created " + workspace.Relative(r, target) + "/")
render.Info(fmt.Sprintf("next: `%s spec generate <feature> --artifact requirements`", prog()))
return 0
}

Expand Down Expand Up @@ -223,6 +312,7 @@ func specShow(args []string) int {
fmt.Println(render.Bold("feature: " + data.FeatureName))
fmt.Println("phase: " + data.Phase)
fmt.Println("language: " + data.Language)
fmt.Println("flow: " + effectiveFlow(data.DevelopmentFlow))
fmt.Println("created: " + data.CreatedAt)
fmt.Printf("ready: %v\n", data.ReadyForImplementation)
fmt.Println("approvals:")
Expand Down
163 changes: 163 additions & 0 deletions internal/cli/spec_flow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package cli

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/protonspy/csdd/internal/templater"
)

// readSpecFlow returns the raw development_flow field written to a spec.json.
func readSpecFlow(t *testing.T, dir, feature string) (SpecJSON, []byte) {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, "specs", feature, "spec.json"))
if err != nil {
t.Fatalf("read spec.json: %v", err)
}
var s SpecJSON
if err := json.Unmarshal(b, &s); err != nil {
t.Fatalf("unmarshal spec.json: %v", err)
}
return s, b
}

// Req 1.1 / 8.3: each valid flow is persisted and surfaced by `spec show`.
func TestSpecInitFlowPersistedAndShown(t *testing.T) {
for _, flow := range []string{"unit", "tdd", "tdd-e2e"} {
flow := flow
t.Run(flow, func(t *testing.T) {
dir := freshWorkspace(t)
if code, _, errOut := run(t, "spec", "init", "feat-"+flow, "--flow", flow, "--root", dir); code != 0 {
t.Fatalf("init --flow %s: code=%d err=%q", flow, code, errOut)
}
s, _ := readSpecFlow(t, dir, "feat-"+flow)
if s.DevelopmentFlow != flow {
t.Errorf("development_flow = %q, want %q", s.DevelopmentFlow, flow)
}
_, showOut, _ := run(t, "spec", "show", "feat-"+flow, "--root", dir)
if !strings.Contains(showOut, "flow:") || !strings.Contains(showOut, flow) {
t.Errorf("show output missing flow %q: %s", flow, showOut)
}
})
}
}

// Req 1.2: an invalid flow is rejected (exit 1) and writes no spec.json.
func TestSpecInitFlowInvalid(t *testing.T) {
dir := freshWorkspace(t)
code, _, errOut := run(t, "spec", "init", "bad-flow", "--flow", "xunit", "--root", dir)
if code != 1 {
t.Errorf("invalid flow: code=%d, want 1", code)
}
if !strings.Contains(errOut, "flow") {
t.Errorf("error should mention flow: %q", errOut)
}
if _, err := os.Stat(filepath.Join(dir, "specs", "bad-flow")); !os.IsNotExist(err) {
t.Error("spec dir must not be created when --flow is invalid")
}
}

// Req 1.3 / 2.2: with no steering default, an omitted flow resolves to tdd.
func TestSpecInitFlowDefaultsToTdd(t *testing.T) {
dir := freshWorkspace(t)
if code, _, errOut := run(t, "spec", "init", "no-flow", "--root", dir); code != 0 {
t.Fatalf("init: code=%d err=%q", code, errOut)
}
s, _ := readSpecFlow(t, dir, "no-flow")
if s.DevelopmentFlow != "tdd" {
t.Errorf("development_flow = %q, want tdd", s.DevelopmentFlow)
}
}

// Req 1.3 / 2.1: a steering-declared default is applied when flow is omitted.
func TestSpecInitFlowSteeringDefault(t *testing.T) {
dir := freshWorkspace(t)
steering := filepath.Join(dir, ".claude", "steering", "zzz-flow-default.md")
body := "---\ninclusion: manual\ndefault_development_flow: tdd-e2e\n---\n# default\n"
if err := os.WriteFile(steering, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if code, _, errOut := run(t, "spec", "init", "inherits", "--root", dir); code != 0 {
t.Fatalf("init: code=%d err=%q", code, errOut)
}
s, _ := readSpecFlow(t, dir, "inherits")
if s.DevelopmentFlow != "tdd-e2e" {
t.Errorf("development_flow = %q, want tdd-e2e", s.DevelopmentFlow)
}
// An explicit --flow overrides the steering default.
if code, _, _ := run(t, "spec", "init", "explicit", "--flow", "unit", "--root", dir); code != 0 {
t.Fatal("init explicit failed")
}
s2, _ := readSpecFlow(t, dir, "explicit")
if s2.DevelopmentFlow != "unit" {
t.Errorf("explicit flow = %q, want unit", s2.DevelopmentFlow)
}
}

// Req 2.2: an invalid steering default does not corrupt init; it falls back to tdd.
func TestResolveDefaultFlowInvalidFallsBack(t *testing.T) {
dir := freshWorkspace(t)
steering := filepath.Join(dir, ".claude", "steering", "zzz-bad.md")
if err := os.WriteFile(steering, []byte("---\ninclusion: manual\ndefault_development_flow: bogus\n---\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := resolveDefaultFlow(dir); got != "tdd" {
t.Errorf("resolveDefaultFlow with invalid steering = %q, want tdd", got)
}
}

// Req 3.1: a legacy spec.json with no development_flow is read as tdd.
func TestEffectiveFlowLegacy(t *testing.T) {
if got := effectiveFlow(""); got != "tdd" {
t.Errorf("effectiveFlow(\"\") = %q, want tdd", got)
}
if got := effectiveFlow("unit"); got != "unit" {
t.Errorf("effectiveFlow(unit) = %q, want unit", got)
}
}

// Req 3.3: saving a spec with no flow omits the field, so legacy files stay clean.
func TestSaveSpecJSONOmitsEmptyFlow(t *testing.T) {
dir := t.TempDir()
if err := saveSpecJSON(dir, SpecJSON{FeatureName: "legacy", Phase: "initialized"}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, "spec.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "development_flow") {
t.Errorf("empty flow must be omitted from spec.json, got:\n%s", b)
}
}

func TestValidDevelopmentFlow(t *testing.T) {
for _, ok := range []string{"unit", "tdd", "tdd-e2e"} {
if !validDevelopmentFlow(ok) {
t.Errorf("validDevelopmentFlow(%q) = false, want true", ok)
}
}
for _, bad := range []string{"", "TDD", "e2e", "unit-test"} {
if validDevelopmentFlow(bad) {
t.Errorf("validDevelopmentFlow(%q) = true, want false", bad)
}
}
}

// Req 1.1: the exported op is the single source of truth (CLI + TUI call it).
func TestSpecInitOpWritesFlow(t *testing.T) {
dir := freshWorkspace(t)
if err := SpecInit(templater.FS, SpecInitOptions{Root: dir, Feature: "via-op", Flow: "unit"}); err != nil {
t.Fatalf("SpecInit: %v", err)
}
s, _ := readSpecFlow(t, dir, "via-op")
if s.DevelopmentFlow != "unit" {
t.Errorf("op-written flow = %q, want unit", s.DevelopmentFlow)
}
if err := SpecInit(templater.FS, SpecInitOptions{Root: dir, Feature: "bad", Flow: "nope"}); err == nil {
t.Error("SpecInit with invalid flow should error")
}
}
1 change: 1 addition & 0 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type specJSON struct {
FeatureName string `json:"feature_name"`
Language string `json:"language"`
Phase string `json:"phase"`
DevelopmentFlow string `json:"development_flow,omitempty"`
Approvals map[string]approvalFlag `json:"approvals"`
ReadyForImplementation bool `json:"ready_for_implementation"`
CreatedAt string `json:"created_at"`
Expand Down
Loading
Loading