rdev/internal/sdlc/classifier_test.go
jordan 6c51469c89 fix: cookbook tree runner stdout/stderr separation and bash brace expansion
- Fix bash brace expansion issue with ${2:-{}} defaults causing extra } chars
- Redirect step status messages to stderr to prevent JSON output pollution
- Redirect wait_pipeline/wait_site/diagnose output to stderr
- Add SDLC handler tests for state, features, tasks, artifacts endpoints
- Add SDLC classifier tests for phase transitions and blocking
- Add SDLC CLI command tests for feature, task, branch, merge operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:15:02 -07:00

589 lines
14 KiB
Go

package sdlc
import "testing"
func makeTestFeature(phase FeaturePhase) *Feature {
f := &Feature{
Slug: "auth",
Title: "Auth",
Phase: phase,
Artifacts: map[ArtifactType]*Artifact{
ArtifactSpec: NewArtifact(ArtifactSpec),
ArtifactDesign: NewArtifact(ArtifactDesign),
ArtifactTasks: NewArtifact(ArtifactTasks),
ArtifactQAPlan: NewArtifact(ArtifactQAPlan),
ArtifactReview: NewArtifact(ArtifactReview),
ArtifactAudit: NewArtifact(ArtifactAudit),
ArtifactQAResults: NewArtifact(ArtifactQAResults),
},
}
return f
}
func TestClassifyDraftNeedsSpec(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateSpec {
t.Errorf("Action = %q, want CREATE_SPEC", cl.Action)
}
if cl.RuleMatched != "needs-spec" {
t.Errorf("RuleMatched = %q, want needs-spec", cl.RuleMatched)
}
}
func TestClassifyDraftSpecDraftNeedsApproval(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
f.GetArtifact(ArtifactSpec).MarkDraft()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionAwaitApproval {
t.Errorf("Action = %q, want AWAIT_APPROVAL", cl.Action)
}
if cl.RuleMatched != "spec-needs-approval" {
t.Errorf("RuleMatched = %q, want spec-needs-approval", cl.RuleMatched)
}
}
func TestClassifyDraftSpecApprovedTransition(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
f.GetArtifact(ArtifactSpec).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseSpecified {
t.Errorf("TransitionTo = %q, want specified", cl.TransitionTo)
}
}
func TestClassifySpecifiedNeedsDesign(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateDesign {
t.Errorf("Action = %q, want CREATE_DESIGN", cl.Action)
}
}
func TestClassifySpecifiedNeedsTasks(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateTasks {
t.Errorf("Action = %q, want CREATE_TASKS", cl.Action)
}
}
func TestClassifySpecifiedNeedsQAPlan(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
f.GetArtifact(ArtifactTasks).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateQAPlan {
t.Errorf("Action = %q, want CREATE_QA_PLAN", cl.Action)
}
}
func TestClassifySpecifiedPlanningComplete(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
f.GetArtifact(ArtifactTasks).Approve("user")
f.GetArtifact(ArtifactQAPlan).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhasePlanned {
t.Errorf("TransitionTo = %q, want planned", cl.TransitionTo)
}
}
func TestClassifyPlannedNeedsBranch(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhasePlanned)
cfg := DefaultConfig("test")
// DefaultConfig has RequireBranch: true, feature has no branch
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: cfg,
})
if cl.Action != ActionCreateBranch {
t.Errorf("Action = %q, want CREATE_BRANCH", cl.Action)
}
if cl.RuleMatched != "needs-branch" {
t.Errorf("RuleMatched = %q, want needs-branch", cl.RuleMatched)
}
}
func TestClassifyPlannedBranchNotRequired(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhasePlanned)
cfg := DefaultConfig("test")
cfg.Compliance.RequireBranch = false
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: cfg,
})
// Should skip branch rule and go to ready-to-implement
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseReady {
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
}
}
func TestClassifyPlannedBranchAlreadyExists(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhasePlanned)
f.Branch = "feature/auth" // Branch already set
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
// Should skip branch rule and go to ready-to-implement
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseReady {
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
}
}
func TestClassifyPlannedTransitionsToReady(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhasePlanned)
f.Branch = "feature/auth" // Branch exists, so needs-branch rule won't fire
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseReady {
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
}
}
func TestClassifyImplementationNextTask(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseImplementation)
f.Tasks = AddTask(nil, "Task 1")
f.Tasks = AddTask(f.Tasks, "Task 2")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionImplementTask {
t.Errorf("Action = %q, want IMPLEMENT_TASK", cl.Action)
}
if cl.TaskID != "task-001" {
t.Errorf("TaskID = %q, want task-001", cl.TaskID)
}
}
func TestClassifyImplementationComplete(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseImplementation)
f.Tasks = AddTask(nil, "Task 1")
f.Tasks, _ = StartTask(f.Tasks, "task-001")
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseReview {
t.Errorf("TransitionTo = %q, want review", cl.TransitionTo)
}
}
func TestClassifyReviewNeeded(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReview)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionReviewCode {
t.Errorf("Action = %q, want REVIEW_CODE", cl.Action)
}
}
func TestClassifyReviewNeedsFix(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReview)
f.GetArtifact(ArtifactReview).MarkNeedsFix()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionFixReviewIssues {
t.Errorf("Action = %q, want FIX_REVIEW_ISSUES", cl.Action)
}
}
func TestClassifyReviewPassed(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReview)
f.GetArtifact(ArtifactReview).MarkPassed()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseAudit {
t.Errorf("TransitionTo = %q, want audit", cl.TransitionTo)
}
}
func TestClassifyAuditNeeded(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseAudit)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionAuditCode {
t.Errorf("Action = %q, want AUDIT_CODE", cl.Action)
}
}
func TestClassifyAuditPassed(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseAudit)
f.GetArtifact(ArtifactAudit).MarkPassed()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseQA {
t.Errorf("TransitionTo = %q, want qa", cl.TransitionTo)
}
}
func TestClassifyQANeeded(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseQA)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionRunQA {
t.Errorf("Action = %q, want RUN_QA", cl.Action)
}
}
func TestClassifyQAPassed(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseQA)
f.GetArtifact(ArtifactQAResults).MarkPassed()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseMerge {
t.Errorf("TransitionTo = %q, want merge", cl.TransitionTo)
}
}
func TestClassifyMerge(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseMerge)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionMergeFeature {
t.Errorf("Action = %q, want MERGE_FEATURE", cl.Action)
}
}
func TestClassifyArchive(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReleased)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionArchive {
t.Errorf("Action = %q, want ARCHIVE", cl.Action)
}
}
func TestClassifyBlocked(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
f.AddBlocker("depends on payments")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionBlocked {
t.Errorf("Action = %q, want BLOCKED", cl.Action)
}
}
func TestClassifyDesignNeedsApproval_Rejected(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).MarkDraft()
f.GetArtifact(ArtifactDesign).Reject("user") // Rejected artifact triggers same rule
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionAwaitApproval {
t.Errorf("Action = %q, want AWAIT_APPROVAL", cl.Action)
}
if cl.RuleMatched != "design-needs-approval" {
t.Errorf("RuleMatched = %q, want design-needs-approval", cl.RuleMatched)
}
}
func TestClassifyAuditHasIssues(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseAudit)
f.GetArtifact(ArtifactAudit).MarkNeedsFix()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionRemediateAudit {
t.Errorf("Action = %q, want REMEDIATE_AUDIT", cl.Action)
}
if cl.RuleMatched != "audit-has-issues" {
t.Errorf("RuleMatched = %q, want audit-has-issues", cl.RuleMatched)
}
}
func TestClassifyQAHasFailures(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseQA)
f.GetArtifact(ArtifactQAResults).MarkFailed()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionFixQAFailures {
t.Errorf("Action = %q, want FIX_QA_FAILURES", cl.Action)
}
if cl.RuleMatched != "qa-has-failures" {
t.Errorf("RuleMatched = %q, want qa-has-failures", cl.RuleMatched)
}
}
func TestClassifyNextCommandAndOutputPath(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.NextCommand != "/spec-feature auth" {
t.Errorf("NextCommand = %q, want /spec-feature auth", cl.NextCommand)
}
if cl.OutputPath != ".sdlc/features/auth/spec.md" {
t.Errorf("OutputPath = %q, want .sdlc/features/auth/spec.md", cl.OutputPath)
}
}
func TestClassifyTaskIDForImplementTask(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseImplementation)
f.Tasks = AddTask(nil, "First Task")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.TaskID != "task-001" {
t.Errorf("TaskID = %q, want task-001", cl.TaskID)
}
if cl.NextCommand == "" {
t.Error("NextCommand should be set for implement-next-task")
}
}
func TestClassifyNoMatchReturnsIdle(t *testing.T) {
// Create a classifier with no rules
c := NewClassifierWithRules([]Rule{})
f := makeTestFeature(PhaseDraft)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionIdle {
t.Errorf("Action = %q, want IDLE", cl.Action)
}
if cl.RuleMatched != "nothing-to-do" {
t.Errorf("RuleMatched = %q, want nothing-to-do", cl.RuleMatched)
}
}
func TestClassifyTasksNeedApproval_Rejected(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
f.GetArtifact(ArtifactTasks).MarkDraft()
f.GetArtifact(ArtifactTasks).Reject("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionAwaitApproval {
t.Errorf("Action = %q, want AWAIT_APPROVAL", cl.Action)
}
if cl.RuleMatched != "tasks-need-approval" {
t.Errorf("RuleMatched = %q, want tasks-need-approval", cl.RuleMatched)
}
}
func TestClassifyQAPlanNeedsApproval_Rejected(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
f.GetArtifact(ArtifactTasks).Approve("user")
f.GetArtifact(ArtifactQAPlan).MarkDraft()
f.GetArtifact(ArtifactQAPlan).Reject("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionAwaitApproval {
t.Errorf("Action = %q, want AWAIT_APPROVAL", cl.Action)
}
if cl.RuleMatched != "qa-plan-needs-approval" {
t.Errorf("RuleMatched = %q, want qa-plan-needs-approval", cl.RuleMatched)
}
}