rdev/internal/sdlc/classifier_test.go
jordan 425ef0f806 feat: add SDLC orchestration - library, CLI, and API integration
Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.

Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence

CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods

API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval

Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)

Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.

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

494 lines
13 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 TestClassifyPlannedTransitionsToReady(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhasePlanned)
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)
}
}
// TestFullLifecycleClassification walks through the entire feature lifecycle.
func TestFullLifecycleClassification(t *testing.T) {
c := NewClassifier()
cfg := DefaultConfig("test")
state := DefaultState("test")
f := makeTestFeature(PhaseDraft)
// Phase: Draft
// Step 1: needs spec
cl := c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionCreateSpec {
t.Fatalf("step1: Action = %q, want CREATE_SPEC", cl.Action)
}
// Step 2: spec created -> needs approval
f.GetArtifact(ArtifactSpec).MarkDraft()
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionAwaitApproval {
t.Fatalf("step2: Action = %q, want AWAIT_APPROVAL", cl.Action)
}
// Step 3: spec approved -> transition to specified
f.GetArtifact(ArtifactSpec).Approve("user")
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionTransition || cl.TransitionTo != PhaseSpecified {
t.Fatalf("step3: Action = %q/%q", cl.Action, cl.TransitionTo)
}
// Phase: Specified
f.Transition(PhaseSpecified)
// Step 4: needs design
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionCreateDesign {
t.Fatalf("step4: Action = %q, want CREATE_DESIGN", cl.Action)
}
// Step 5: design approved -> needs tasks
f.GetArtifact(ArtifactDesign).Approve("user")
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionCreateTasks {
t.Fatalf("step5: Action = %q, want CREATE_TASKS", cl.Action)
}
// Step 6: tasks approved -> needs qa plan
f.GetArtifact(ArtifactTasks).Approve("user")
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionCreateQAPlan {
t.Fatalf("step6: Action = %q, want CREATE_QA_PLAN", cl.Action)
}
// Step 7: qa plan approved -> transition to planned
f.GetArtifact(ArtifactQAPlan).Approve("user")
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionTransition || cl.TransitionTo != PhasePlanned {
t.Fatalf("step7: Action = %q/%q", cl.Action, cl.TransitionTo)
}
// Phase: Planned -> Ready -> Implementation
f.Transition(PhasePlanned)
f.Transition(PhaseReady)
f.Transition(PhaseImplementation)
// Add tasks
f.Tasks = AddTask(nil, "Create user model")
f.Tasks = AddTask(f.Tasks, "Add validation")
// Step 8: implement next task
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionImplementTask {
t.Fatalf("step8: Action = %q, want IMPLEMENT_TASK", cl.Action)
}
// Complete all tasks
f.Tasks, _ = StartTask(f.Tasks, "task-001")
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
f.Tasks, _ = StartTask(f.Tasks, "task-002")
f.Tasks, _ = CompleteTask(f.Tasks, "task-002")
// Step 9: implementation complete -> transition to review
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionTransition || cl.TransitionTo != PhaseReview {
t.Fatalf("step9: Action = %q/%q", cl.Action, cl.TransitionTo)
}
// Phase: Review
f.Transition(PhaseReview)
f.GetArtifact(ArtifactReview).MarkPassed()
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.TransitionTo != PhaseAudit {
t.Fatalf("review->audit: TransitionTo = %q", cl.TransitionTo)
}
// Phase: Audit
f.Transition(PhaseAudit)
f.GetArtifact(ArtifactAudit).MarkPassed()
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.TransitionTo != PhaseQA {
t.Fatalf("audit->qa: TransitionTo = %q", cl.TransitionTo)
}
// Phase: QA
f.Transition(PhaseQA)
f.GetArtifact(ArtifactQAResults).MarkPassed()
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.TransitionTo != PhaseMerge {
t.Fatalf("qa->merge: TransitionTo = %q", cl.TransitionTo)
}
// Phase: Merge
f.Transition(PhaseMerge)
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionMergeFeature {
t.Fatalf("merge: Action = %q, want MERGE_FEATURE", cl.Action)
}
// Phase: Released
f.Transition(PhaseReleased)
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
if cl.Action != ActionArchive {
t.Fatalf("released: Action = %q, want ARCHIVE", cl.Action)
}
}