diff --git a/.twig/.twig/config b/.twig/.twig/config new file mode 100644 index 00000000..2ce9558c --- /dev/null +++ b/.twig/.twig/config @@ -0,0 +1 @@ +{"organization":"dangreen-msft","project":"Twig","team":"","processTemplate":"","auth":{"method":"azcli"},"defaults":{"areaPaths":["Twig"],"areaPathEntries":[{"path":"Twig","includeChildren":false,"semanticsLabel":"exact"}],"mode":"sprint"},"seed":{"staleDays":14},"display":{"hints":true,"treeDepth":10,"treeDepthUp":2,"treeDepthDown":10,"treeDepthSideways":1,"icons":"nerd","cacheStaleMinutes":5,"cacheStaleMinutesReadOnly":15,"fillRateThreshold":0.4,"maxExtraColumns":3},"user":{"displayName":"Daniel Green"},"git":{"branchTemplate":"feature/{id}-{title}","branchPattern":"(?:^|/)(?\u003Cid\u003E\\d{3,})(?:-|/|$)","commitTemplate":"{type}(#{id}): {message}","defaultTarget":"main","autoLink":true,"autoTransition":true,"hooks":{"prepareCommitMsg":true,"commitMsg":true,"postCheckout":true}},"flow":{"autoAssign":"if-unassigned","autoSaveOnDone":true,"offerPrOnDone":true},"workspace":{},"tracking":{"cleanupPolicy":"none"},"areas":{"mode":"under"},"typeAppearances":[{"name":"Issue","color":"339947","iconId":"icon_clipboard_issue"},{"name":"Epic","color":"E06C00","iconId":"icon_crown"},{"name":"Task","color":"A4880A","iconId":"icon_check_box"},{"name":"Test Case","color":"004B50","iconId":"icon_test_case"},{"name":"Test Plan","color":"004B50","iconId":"icon_test_plan"},{"name":"Test Suite","color":"004B50","iconId":"icon_test_suite"},{"name":"Shared Steps","color":"004B50","iconId":"icon_test_step"},{"name":"Shared Parameter","color":"004B50","iconId":"icon_test_parameter"},{"name":"Code Review Request","color":"B4009E","iconId":"icon_code_review"},{"name":"Code Review Response","color":"B4009E","iconId":"icon_code_response"},{"name":"Feedback Request","color":"004B50","iconId":"icon_review"},{"name":"Feedback Response","color":"004B50","iconId":"icon_response"}]} \ No newline at end of file diff --git a/.twig/.twig/dangreen-msft/Twig/twig.db b/.twig/.twig/dangreen-msft/Twig/twig.db new file mode 100644 index 00000000..241b6912 Binary files /dev/null and b/.twig/.twig/dangreen-msft/Twig/twig.db differ diff --git a/.twig/.twig/dangreen-msft/Twig/twig.db-shm b/.twig/.twig/dangreen-msft/Twig/twig.db-shm new file mode 100644 index 00000000..fe9ac284 Binary files /dev/null and b/.twig/.twig/dangreen-msft/Twig/twig.db-shm differ diff --git a/.twig/.twig/dangreen-msft/Twig/twig.db-wal b/.twig/.twig/dangreen-msft/Twig/twig.db-wal new file mode 100644 index 00000000..e69de29b diff --git a/.twig/config b/.twig/config index 2ce9558c..11ede821 100644 --- a/.twig/config +++ b/.twig/config @@ -1 +1 @@ -{"organization":"dangreen-msft","project":"Twig","team":"","processTemplate":"","auth":{"method":"azcli"},"defaults":{"areaPaths":["Twig"],"areaPathEntries":[{"path":"Twig","includeChildren":false,"semanticsLabel":"exact"}],"mode":"sprint"},"seed":{"staleDays":14},"display":{"hints":true,"treeDepth":10,"treeDepthUp":2,"treeDepthDown":10,"treeDepthSideways":1,"icons":"nerd","cacheStaleMinutes":5,"cacheStaleMinutesReadOnly":15,"fillRateThreshold":0.4,"maxExtraColumns":3},"user":{"displayName":"Daniel Green"},"git":{"branchTemplate":"feature/{id}-{title}","branchPattern":"(?:^|/)(?\u003Cid\u003E\\d{3,})(?:-|/|$)","commitTemplate":"{type}(#{id}): {message}","defaultTarget":"main","autoLink":true,"autoTransition":true,"hooks":{"prepareCommitMsg":true,"commitMsg":true,"postCheckout":true}},"flow":{"autoAssign":"if-unassigned","autoSaveOnDone":true,"offerPrOnDone":true},"workspace":{},"tracking":{"cleanupPolicy":"none"},"areas":{"mode":"under"},"typeAppearances":[{"name":"Issue","color":"339947","iconId":"icon_clipboard_issue"},{"name":"Epic","color":"E06C00","iconId":"icon_crown"},{"name":"Task","color":"A4880A","iconId":"icon_check_box"},{"name":"Test Case","color":"004B50","iconId":"icon_test_case"},{"name":"Test Plan","color":"004B50","iconId":"icon_test_plan"},{"name":"Test Suite","color":"004B50","iconId":"icon_test_suite"},{"name":"Shared Steps","color":"004B50","iconId":"icon_test_step"},{"name":"Shared Parameter","color":"004B50","iconId":"icon_test_parameter"},{"name":"Code Review Request","color":"B4009E","iconId":"icon_code_review"},{"name":"Code Review Response","color":"B4009E","iconId":"icon_code_response"},{"name":"Feedback Request","color":"004B50","iconId":"icon_review"},{"name":"Feedback Response","color":"004B50","iconId":"icon_response"}]} \ No newline at end of file +{"organization":"dangreen-msft","project":"Twig","team":"","processTemplate":"","auth":{"method":"azcli"},"defaults":{"areaPaths":["Twig"],"areaPathEntries":[{"path":"Twig","includeChildren":false,"semanticsLabel":"exact"}],"mode":"sprint"},"seed":{"staleDays":14},"display":{"hints":true,"treeDepth":10,"treeDepthUp":2,"treeDepthDown":10,"treeDepthSideways":1,"icons":"nerd","cacheStaleMinutes":5,"cacheStaleMinutesReadOnly":15,"fillRateThreshold":0.4,"maxExtraColumns":3},"user":{"displayName":"Daniel Green"},"git":{"branchPattern":"(?:^|/)(?\u003Cid\u003E\\d{3,})(?:-|/|$)"},"workspace":{},"tracking":{"cleanupPolicy":"none"},"areas":{"mode":"under"},"typeAppearances":[{"name":"Issue","color":"339947","iconId":"icon_clipboard_issue"},{"name":"Epic","color":"E06C00","iconId":"icon_crown"},{"name":"Task","color":"A4880A","iconId":"icon_check_box"},{"name":"Test Case","color":"004B50","iconId":"icon_test_case"},{"name":"Test Plan","color":"004B50","iconId":"icon_test_plan"},{"name":"Test Suite","color":"004B50","iconId":"icon_test_suite"},{"name":"Shared Steps","color":"004B50","iconId":"icon_test_step"},{"name":"Shared Parameter","color":"004B50","iconId":"icon_test_parameter"},{"name":"Code Review Request","color":"B4009E","iconId":"icon_code_review"},{"name":"Code Review Response","color":"B4009E","iconId":"icon_code_response"},{"name":"Feedback Request","color":"004B50","iconId":"icon_review"},{"name":"Feedback Response","color":"004B50","iconId":"icon_response"}]} \ No newline at end of file diff --git a/.twig/dangreen-msft/Twig/twig.db b/.twig/dangreen-msft/Twig/twig.db index 241b6912..2f634539 100644 Binary files a/.twig/dangreen-msft/Twig/twig.db and b/.twig/dangreen-msft/Twig/twig.db differ diff --git a/.twig/dangreen-msft/Twig/twig.db-shm b/.twig/dangreen-msft/Twig/twig.db-shm new file mode 100644 index 00000000..025aee86 Binary files /dev/null and b/.twig/dangreen-msft/Twig/twig.db-shm differ diff --git a/.twig/dangreen-msft/Twig/twig.db-wal b/.twig/dangreen-msft/Twig/twig.db-wal new file mode 100644 index 00000000..0aabb89b Binary files /dev/null and b/.twig/dangreen-msft/Twig/twig.db-wal differ diff --git a/.twig/prompt.json b/.twig/prompt.json new file mode 100644 index 00000000..b9a77138 --- /dev/null +++ b/.twig/prompt.json @@ -0,0 +1,15 @@ +{ + "text": "\uEAB3 #2606 Rewrite RouteCommand with full routing logic and DI [Doing]", + "id": 2606, + "type": "Task", + "typeBadge": "\uEAB3 ", + "title": "Rewrite RouteCommand with full routing logic and DI", + "state": "Doing", + "stateCategory": "InProgress", + "isDirty": false, + "typeColor": "#A4880A", + "typeTextColor": "#000000", + "stateColor": "#007acc", + "branch": null, + "generatedAt": "2026-04-29T23:56:53.0059392Z" +} \ No newline at end of file diff --git a/docs/projects/polyphony-core-engine.plan.md b/docs/projects/polyphony-core-engine.plan.md new file mode 100644 index 00000000..27855e8d --- /dev/null +++ b/docs/projects/polyphony-core-engine.plan.md @@ -0,0 +1,664 @@ +# Phase 1: Polyphony Core Engine + +**Epic:** #2581 — Phase 1: Polyphony Core Engine +> **Status**: 🔨 In Progress +**Author:** Copilot (architect agent) + +--- + +## Executive Summary + +This plan implements the Polyphony deterministic routing engine — the core state machine that powers conductor SDLC workflows. Given a work item ID and a process configuration, Polyphony inspects the work item's type, state, capabilities, and child hierarchy (all read from the twig SQLite cache) to produce a structured JSON routing decision: which SDLC phase the item is in and what action to take next. Phase 1 delivers working implementations of all three CLI commands (`route`, `validate`, `hierarchy`), backed by a shared routing engine with proper DI, cache access, phase detection, transition validation, and branch-name resolution. The result is a fully deterministic, AOT-compiled binary that conductor workflows can invoke for routing decisions without hardcoding type assumptions. + +## Background + +### Current State + +Polyphony exists as a scaffolded .NET 10 AOT CLI with three stub commands that return placeholder JSON. The project structure is sound: + +| Component | State | Location | +|-----------|-------|----------| +| `RouteCommand` | Stub — returns `"phase": "not_implemented"` | `src/Polyphony/Commands/RouteCommand.cs` | +| `ValidateCommand` | Stub — returns `"is_valid": false` | `src/Polyphony/Commands/ValidateCommand.cs` | +| `HierarchyCommand` | Stub — returns `"type": "Unknown"` | `src/Polyphony/Commands/HierarchyCommand.cs` | +| `ProcessConfigLoader` | Working — parses YAML correctly | `src/Polyphony/Configuration/ProcessConfigLoader.cs` | +| `ProcessConfig` model | Working — all config types defined | `src/Polyphony/Configuration/ProcessConfig.cs` | +| Result models | Working — `RouteResult`, `ValidateResult`, `HierarchyResult` | `src/Polyphony/Models/` | +| `PolyphonyJsonContext` | Working — source-generated JSON | `src/Polyphony/PolyphonyJsonContext.cs` | +| DI container | **Not set up** — commands have no constructor injection | `src/Polyphony/Program.cs` | +| Twig cache access | **Not wired** — project references exist but not used | `Polyphony.csproj` | + +### Dependencies on Twig.Domain and Twig.Infrastructure + +Polyphony already has project references to `Twig.Domain` and `Twig.Infrastructure` (located at `../../../twig2/src/`). These provide the full data access layer: + +**Twig.Domain provides:** +- `WorkItem` aggregate — `Id`, `Type` (`WorkItemType`), `Title`, `State`, `ParentId`, `IsSeed`, `Fields` +- `WorkItemType` value object — type-safe wrapper with well-known statics (`Epic`, `Issue`, `Task`) +- `StateCategory` enum — `Proposed`, `InProgress`, `Resolved`, `Completed`, `Removed`, `Unknown` +- `ProcessConfiguration` / `TypeConfig` — transition rules, allowed child types, state entries +- `StateEntry` — `(Name, Category, Color)` for each state in a type's workflow +- `TransitionKind` enum — `None`, `Forward`, `Cut` +- `StateCategoryResolver` — resolves state name → `StateCategory` (with fallback heuristics) +- `StateTransitionService` — evaluates whether a state transition is allowed + +**Twig.Infrastructure provides:** +- `SqliteCacheStore` — WAL-mode SQLite connection with schema versioning +- `IWorkItemRepository` / `SqliteWorkItemRepository` — full CRUD for work items + - `GetByIdAsync(int id)` → `WorkItem?` + - `GetChildrenAsync(int parentId)` → `IReadOnlyList` + - `GetParentChainAsync(int id)` → `IReadOnlyList` +- `IProcessTypeStore` / `SqliteProcessTypeStore` — cached process type metadata +- `IContextStore` / `SqliteContextStore` — active work item context +- `TwigServiceRegistration.AddTwigCoreServices()` — DI registration extension method +- `TwigPaths` — resolves `.twig/` directory and `cache.db` path + +### Conductor SDLC Workflow Context + +Polyphony serves as the routing oracle for conductor SDLC workflows (defined in `.github/skills/twig-sdlc/`). The workflow phases are: + +| Phase | Agent/Script | Polyphony Role | +|-------|-------------|----------------| +| 0. Preflight | `preflight-check.ps1` | None (pre-routing) | +| 1. Intake | Sonnet agent | Provides `route` to detect current phase | +| 2. Planning | Opus agent | `validate` confirms `begin_planning` is legal | +| 3. Seeding | `seed-from-plan.ps1` | `route` confirms seeding is needed | +| 4. Implementation | Multiple agents | `route` identifies tasks, `validate` checks transitions | +| 5. Close-out | Opus agent | `validate` confirms completion transition | +| 6. Filing | Sonnet agent | None (post-routing) | + +### Process Config Semantics + +The `.conductor/process-config.yaml` defines the type system: + +- **Capabilities**: `plannable` (can have a plan document), `implementable` (can have code changes) +- **Transitions**: Event → target state mappings per type (e.g., `begin_planning` → `Doing`) +- **Branch strategy**: Templates for feature, planning, and PG branches +- **Review policies**: Agent/human review requirements per PR category + +### Key Design Principle: P8 — Scripts Over Agents + +Per conductor design principle P8, Polyphony is explicitly a **deterministic script** (not an agent). It makes routing decisions based on observable state without LLM involvement. This means: +- All phase detection is rule-based +- No ambiguity in routing decisions +- Same inputs always produce same outputs +- Exit codes drive conductor branching + +## Problem Statement + +Conductor SDLC workflows currently cannot invoke Polyphony for routing decisions because all three commands are stubs. The `route` command returns `"phase": "not_implemented"`, `validate` always returns `"is_valid": false`, and `hierarchy` returns `"type": "Unknown"`. This means: + +1. **No automated phase detection** — Conductor cannot determine whether a work item needs planning, seeding, implementation, or close-out. Routing logic remains hardcoded in agent prompts and shell scripts, tightly coupled to the Basic process template. + +2. **No transition validation** — There's no way to programmatically verify that a lifecycle event (e.g., `begin_planning`, `implementation_complete`) is legal for a given work item's current state. Agents must guess or hardcode. + +3. **No hierarchy introspection** — Conductor cannot query the work item tree with capability annotations (plannable/implementable), making it impossible to determine which children need work. + +4. **No DI or cache access** — The commands don't connect to the twig SQLite cache at all, so they have no access to work item state, hierarchy, or process metadata. + +## Goals and Non-Goals + +### Goals + +1. **Implement the `route` command** — Given a work item ID, determine its SDLC phase (`needs_planning`, `needs_seeding`, `ready_for_implementation`, `in_progress`, `ready_for_completion`, `done`, `removed`) and the next action to take (`plan`, `seed`, `implement`, `monitor`, `close`, `none`). + +2. **Implement the `validate` command** — Given a work item ID and a lifecycle event name, determine whether the transition is legal based on the process config and current state, returning the target state if valid. + +3. **Implement the `hierarchy` command** — Given a work item ID and depth, walk the hierarchy and return each node annotated with capabilities from the process config. + +4. **Establish DI and cache access** — Wire up Twig.Infrastructure services so commands can read from the twig SQLite cache. Commands receive services via constructor injection. + +5. **Define exit code semantics** — Establish a clear exit code scheme that conductor scripts can branch on (0 = success, 1 = routing error, 2 = config error, 3 = cache error). + +6. **Achieve ≥90% test coverage** on routing logic — Phase detection is the critical path; it must be thoroughly tested with unit tests covering all type/state combinations. + +### Non-Goals + +- **Write access to the twig cache** — Polyphony is read-only. State transitions are executed by twig, not Polyphony. +- **ADO API calls** — Polyphony reads the local cache only. It never contacts Azure DevOps directly. +- **Plan file parsing** — Polyphony does not read or parse `.plan.md` files. Phase detection is based on work item state and hierarchy, not filesystem artifacts. +- **LLM/AI integration** — Polyphony is fully deterministic (P8). +- **Custom process template support** — Phase 1 targets the Basic process template. Agile/Scrum/CMMI support is Phase 2. +- **Interactive output** — All output is structured JSON to stdout. No Spectre.Console rendering in Phase 1 (the dependency is there for future use). + +## Requirements + +### Functional Requirements + +| ID | Requirement | +|----|-------------| +| FR-1 | `route` command loads work item from twig cache, determines SDLC phase and action, outputs JSON to stdout | +| FR-2 | `route` command resolves workspace hints (feature branch, PG branch) from process config branch strategy | +| FR-3 | `validate` command checks lifecycle event against process config transitions and current work item state | +| FR-4 | `validate` command evaluates preconditions (e.g., `all_children_complete` requires all children in Completed category) | +| FR-5 | `hierarchy` command walks the work item tree to specified depth, annotating each node with capabilities | +| FR-6 | All commands load process config from `--config` path (default `.conductor/process-config.yaml`) | +| FR-7 | All commands accept `--twig-dir` to locate the twig cache directory (default `.twig`) | +| FR-8 | Exit codes follow a defined scheme: 0 (success), 1 (routing/validation failure), 2 (config error), 3 (cache error) | + +### Non-Functional Requirements + +| ID | Requirement | +|----|-------------| +| NFR-1 | AOT-compatible — no reflection, no dynamic code generation | +| NFR-2 | All JSON serialization through `PolyphonyJsonContext` (source-generated) | +| NFR-3 | Startup-to-output latency < 100ms for cached work items | +| NFR-4 | Cache-only — no network calls | +| NFR-5 | Deterministic — same inputs always produce same outputs | +| NFR-6 | TreatWarningsAsErrors — zero warnings in build | + +## Proposed Design + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ CLI Layer │ +│ RouteCommand │ ValidateCommand │ HierarchyCmd │ +│ (ConsoleAppFramework source-gen commands) │ +└────────┬───────────────┬──────────────────┬──────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────┐ +│ Routing Engine │ +│ PhaseDetector │ TransitionValidator │ HierWalk │ +│ ActionResolver │ BranchNameResolver │ +└────────┬───────────────┬──────────────────┬──────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────┐ +│ Data Access (read-only) │ +│ IWorkItemRepository │ IProcessTypeStore │ +│ (from Twig.Infrastructure via DI) │ +└────────┬───────────────────────────────────┬─────────┘ + │ │ + ▼ ▼ +┌──────────────────┐ ┌─────────────────────┐ +│ ProcessConfig │ │ SQLite Cache │ +│ (YAML file) │ │ (.twig/cache.db) │ +└──────────────────┘ └─────────────────────┘ +``` + +### Key Components + +#### 1. SDLC Phase and Action Types (`Routing/SdlcPhase.cs`, `Routing/SdlcAction.cs`) + +Static string constants (not enums, for JSON serialization simplicity and forward compatibility): + +```csharp +public static class SdlcPhase +{ + public const string NeedsPlanning = "needs_planning"; + public const string NeedsSeeding = "needs_seeding"; + public const string ReadyForImplementation = "ready_for_implementation"; + public const string InProgress = "in_progress"; + public const string ReadyForCompletion = "ready_for_completion"; + public const string Done = "done"; + public const string Removed = "removed"; + public const string Unknown = "unknown"; +} + +public static class SdlcAction +{ + public const string Plan = "plan"; + public const string Seed = "seed"; + public const string Implement = "implement"; + public const string Monitor = "monitor"; + public const string Close = "close"; + public const string None = "none"; +} +``` + +**Design Decision:** String constants rather than enums avoid `JsonStringEnumConverter` (which requires reflection in some modes) and allow conductor scripts to match on string values directly. Forward-compatible if new phases are added. + +#### 2. PhaseDetector (`Routing/PhaseDetector.cs`) + +The core state machine. Determines the SDLC phase for a work item based on its type, capabilities, state, and children: + +```csharp +public sealed class PhaseDetector(ProcessConfig processConfig) +{ + public RoutingDecision Detect(WorkItem item, IReadOnlyList children); +} + +public sealed record RoutingDecision +{ + public required string Phase { get; init; } + public required string Action { get; init; } + public string? Message { get; init; } +} +``` + +**Phase Detection Rules:** + +| Type Capabilities | State Category | Children | Phase | Action | +|-------------------|---------------|----------|-------|--------| +| plannable | Proposed | none | `needs_planning` | `plan` | +| plannable | Proposed | has children | `needs_planning` | `plan` | +| plannable | InProgress | none | `needs_seeding` | `seed` | +| plannable | InProgress | all Proposed | `ready_for_implementation` | `implement` | +| plannable | InProgress | mixed | `in_progress` | `monitor` | +| plannable | InProgress | all Completed | `ready_for_completion` | `close` | +| implementable only | Proposed | — | `ready_for_implementation` | `implement` | +| implementable only | InProgress | — | `in_progress` | `monitor` | +| any | Completed | — | `done` | `none` | +| any | Removed | — | `removed` | `none` | + +For items with both `plannable` and `implementable` capabilities (like Issue in Basic process): +- **Proposed + no children** → `needs_planning` (plan first, then decide on decomposition) +- **InProgress + no children** → `ready_for_implementation` (direct implementation, no decomposition needed) +- **InProgress + children** → follows the children-based logic above + +**Design Decision:** Phase detection operates on the Polyphony-level `ProcessConfig` (from YAML), not the Twig.Domain `ProcessConfiguration` (from ADO API cache). This is intentional — Polyphony's routing rules are defined in the repo-local process config, while Twig.Domain's `ProcessConfiguration` represents the ADO process template's raw state machine. The two may diverge (e.g., Polyphony adds `plannable`/`implementable` capabilities that ADO doesn't know about). + +However, **state category resolution** uses `StateCategoryResolver` from Twig.Domain, which maps state names (like "To Do", "Doing", "Done") to `StateCategory` values. This is the bridge between ADO state names and Polyphony's phase logic. + +#### 3. TransitionValidator (`Routing/TransitionValidator.cs`) + +Validates lifecycle event transitions: + +```csharp +public sealed class TransitionValidator(ProcessConfig processConfig) +{ + public ValidateResult Validate(WorkItem item, string eventName, IReadOnlyList children); +} +``` + +**Validation Logic:** +1. Look up the event in `ProcessConfig.Transitions[item.Type]` +2. If event not found → invalid, "unknown event for type" +3. If event found → target state is `transitions[eventName]` +4. Check preconditions: + - `all_children_complete`: All children must be in `Completed` state category + - `begin_planning`: Item must be in `Proposed` state category + - `begin_implementation`: Item must be in `Proposed` or `InProgress` category + - `implementation_complete`: Item must be in `InProgress` category +5. Return `ValidateResult` with `IsValid`, target state, and message + +#### 4. HierarchyWalker (`Routing/HierarchyWalker.cs`) + +Walks the work item tree and annotates nodes: + +```csharp +public sealed class HierarchyWalker(ProcessConfig processConfig, IWorkItemRepository repository) +{ + public async Task WalkAsync(int rootId, int maxDepth, CancellationToken ct); +} +``` + +Recursively loads children via `IWorkItemRepository.GetChildrenAsync()` up to `maxDepth`, annotating each node with `Capabilities` from `ProcessConfig.Types[type].Capabilities`. + +#### 5. BranchNameResolver (`Routing/BranchNameResolver.cs`) + +Resolves branch name templates from `ProcessConfig.BranchStrategy`: + +```csharp +public static class BranchNameResolver +{ + public static WorkspaceHint Resolve(ProcessConfig config, WorkItem rootItem, string slug); +} +``` + +Substitutes `{root_id}`, `{slug}` in templates like `feature/{root_id}-{slug}`. + +#### 6. TwigCacheLocator (`Infrastructure/TwigCacheLocator.cs`) + +Locates the twig cache database: + +```csharp +public static class TwigCacheLocator +{ + public static string ResolveCachePath(string? twigDir = null); +} +``` + +Searches for `.twig/cache.db` starting from the specified directory (or CWD), walking up to find the repo root. + +#### 7. Exit Codes (`ExitCodes.cs`) + +```csharp +public static class ExitCodes +{ + public const int Success = 0; + public const int RoutingFailure = 1; + public const int ConfigError = 2; + public const int CacheError = 3; +} +``` + +#### 8. DI Registration (`PolyphonyServiceRegistration.cs`) + +```csharp +public static class PolyphonyServiceRegistration +{ + public static IServiceCollection AddPolyphonyServices( + this IServiceCollection services, string configPath, string? twigDir); +} +``` + +Registers: +- `ProcessConfig` (from YAML via `ProcessConfigLoader`) +- `SqliteCacheStore` (from twig cache path) +- `IWorkItemRepository` (via `SqliteWorkItemRepository`) +- `IProcessTypeStore` (via `SqliteProcessTypeStore`) +- `PhaseDetector` +- `TransitionValidator` +- `HierarchyWalker` + +### Data Flow + +#### Route Command Flow + +``` +1. CLI receives: --work-item 1234 --config .conductor/process-config.yaml +2. DI resolves: ProcessConfig, IWorkItemRepository, PhaseDetector +3. Load work item: repository.GetByIdAsync(1234) + → If null: exit 3 (cache error — work item not in cache) +4. Load children: repository.GetChildrenAsync(1234) +5. Resolve state category: StateCategoryResolver.Resolve(item.State, stateEntries) +6. Detect phase: PhaseDetector.Detect(item, children) + → Returns RoutingDecision { Phase, Action, Message } +7. Resolve workspace hint: BranchNameResolver.Resolve(config, item, slug) +8. Build RouteResult and serialize via PolyphonyJsonContext +9. Write JSON to stdout, return exit code 0 +``` + +#### Validate Command Flow + +``` +1. CLI receives: --work-item 1234 --event begin_planning --config ... +2. DI resolves: ProcessConfig, IWorkItemRepository, TransitionValidator +3. Load work item: repository.GetByIdAsync(1234) +4. Load children (for precondition checks): repository.GetChildrenAsync(1234) +5. Validate: TransitionValidator.Validate(item, eventName, children) +6. Build ValidateResult and serialize +7. Write JSON to stdout, return exit code 0 (valid) or 1 (invalid) +``` + +#### Hierarchy Command Flow + +``` +1. CLI receives: --work-item 1234 --depth 3 --config ... +2. DI resolves: ProcessConfig, HierarchyWalker +3. Walk: HierarchyWalker.WalkAsync(1234, 3) + → Recursively loads children, annotates with capabilities +4. Build HierarchyResult tree and serialize +5. Write JSON to stdout, return exit code 0 +``` + +### Design Decisions + +| Decision | Rationale | +|----------|-----------| +| String constants for phases/actions (not enums) | Avoids reflection-based enum serialization; forward-compatible; conductor scripts match on strings | +| Read Polyphony ProcessConfig from YAML, not Twig.Domain ProcessConfiguration | Polyphony's routing concepts (plannable/implementable capabilities) are repo-specific, not ADO-native | +| Use Twig.Domain's `StateCategoryResolver` for state → category | Reuses battle-tested state name mapping; avoids duplicating fallback heuristics | +| Read-only cache access | Polyphony is an observer, not a mutator; state changes go through twig CLI | +| Per-command service resolution (not global static) | Testable, injectable, AOT-safe | +| `--twig-dir` parameter on all commands | Allows worktree scenarios where `.twig/` is not at CWD | + +## Dependencies + +### External Dependencies + +| Dependency | Version | Purpose | +|------------|---------|---------| +| ConsoleAppFramework | 5.7.13 | Source-gen CLI framework | +| Microsoft.Data.Sqlite | 10.0.6 | SQLite cache access | +| SQLitePCLRaw.bundle_e_sqlite3 | 2.1.11 | Native SQLite binding | +| YamlDotNet | 16.3.0 | Process config parsing | +| Microsoft.Extensions.DependencyInjection | 10.0.6 | DI container | + +### Internal Dependencies + +| Dependency | Purpose | +|------------|---------| +| Twig.Domain | Work item models, state categories, process configuration types | +| Twig.Infrastructure | SQLite cache access, repository implementations, DI registration | + +### Sequencing Constraints + +- Twig.Domain and Twig.Infrastructure must be buildable (they are — project references exist and build today) +- The twig SQLite cache must be populated (via `twig sync`) before Polyphony can route + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| ConsoleAppFramework DI integration pattern may differ from assumed API | Medium | Medium | Verify DI integration in PG-1; ConsoleAppFramework v5 supports `IServiceProvider` — validate exact pattern early | +| Twig.Domain/Infrastructure API changes break Polyphony | Low | High | Pin to specific twig2 commit; add integration tests that validate interface compatibility | +| State category resolution edge cases for custom states | Medium | Low | Rely on `StateCategoryResolver.FallbackCategory()` for unknown states; log warnings | +| AOT trimming removes needed types | Low | High | All serialized types registered in `PolyphonyJsonContext`; test with `PublishAot` in CI | + +## Open Questions + +| # | Question | Severity | Context | +|---|----------|----------|---------| +| OQ-1 | What is the exact ConsoleAppFramework v5 API for DI integration? Is it `ConsoleApp.ServiceProvider = sp` or builder pattern? | Low | Easily validated by reading ConsoleAppFramework source/docs during PG-1. Will not block design. | +| OQ-2 | Should `route` produce different exit codes for different phases (e.g., exit 10 for needs_planning, 11 for needs_seeding) to allow shell-level branching? | Low | Current design uses exit 0 for all successful routes with phase in JSON. Conductor scripts parse JSON. Can be added later if needed. | +| OQ-3 | How should Polyphony handle seed work items (negative IDs) in the hierarchy? | Low | Seeds are virtual items in the twig cache. For Phase 1, treat them as regular work items. Phase 2 can add seed-specific routing if needed. | +| OQ-4 | Should the `route` command accept a `--slug` parameter for branch name resolution, or derive it from the work item title? | Low | For Phase 1, derive from title (lowercase, hyphenated, truncated). Can add `--slug` override later. | + +## Files Affected + +### New Files + +| File Path | Purpose | +|-----------|---------| +| `src/Polyphony/Routing/SdlcPhase.cs` | SDLC phase string constants | +| `src/Polyphony/Routing/SdlcAction.cs` | SDLC action string constants | +| `src/Polyphony/Routing/RoutingDecision.cs` | Phase + action result record | +| `src/Polyphony/Routing/PhaseDetector.cs` | Core state machine — determines phase from work item state and children | +| `src/Polyphony/Routing/TransitionValidator.cs` | Lifecycle event validation against process config and preconditions | +| `src/Polyphony/Routing/HierarchyWalker.cs` | Recursive hierarchy traversal with capability annotations | +| `src/Polyphony/Routing/BranchNameResolver.cs` | Branch name template resolution from process config | +| `src/Polyphony/Infrastructure/TwigCacheLocator.cs` | Locates `.twig/cache.db` from working directory or `--twig-dir` | +| `src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs` | DI registration for all Polyphony + Twig services | +| `src/Polyphony/ExitCodes.cs` | Exit code constants (0=success, 1=routing failure, 2=config error, 3=cache error) | +| `tests/Polyphony.Tests/Routing/PhaseDetectorTests.cs` | Unit tests for phase detection — all type/state/children combinations | +| `tests/Polyphony.Tests/Routing/TransitionValidatorTests.cs` | Unit tests for transition validation | +| `tests/Polyphony.Tests/Routing/HierarchyWalkerTests.cs` | Unit tests for hierarchy walking | +| `tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs` | Unit tests for branch name resolution | +| `tests/Polyphony.Tests/Infrastructure/TwigCacheLocatorTests.cs` | Unit tests for cache location logic | +| `tests/Polyphony.Tests/TestFixtures/WorkItemBuilder.cs` | Test helper — fluent builder for `WorkItem` instances | +| `tests/Polyphony.Tests/TestFixtures/ProcessConfigBuilder.cs` | Test helper — fluent builder for `ProcessConfig` instances | + +### Modified Files + +| File Path | Changes | +|-----------|---------| +| `src/Polyphony/Program.cs` | Add DI setup, configure `ConsoleApp` with service provider | +| `src/Polyphony/Commands/RouteCommand.cs` | Replace stub with full implementation using injected `PhaseDetector`, `BranchNameResolver` | +| `src/Polyphony/Commands/ValidateCommand.cs` | Replace stub with full implementation using injected `TransitionValidator` | +| `src/Polyphony/Commands/HierarchyCommand.cs` | Replace stub with full implementation using injected `HierarchyWalker` | +| `src/Polyphony/PolyphonyJsonContext.cs` | Add `RoutingDecision` and any new serialized types | +| `src/Polyphony/Models/RouteResult.cs` | Potentially add fields (e.g., `Capabilities`, `ChildSummary`) | +| `tests/Polyphony.Tests/Polyphony.Tests.csproj` | Add project reference to Twig.Domain (for test builders) | + +--- + +## ADO Work Item Structure + +### Issue 1: Core Infrastructure — DI, Cache Access, and Routing Types + +**Goal:** Establish the foundational infrastructure that all commands depend on: DI container setup, twig cache connectivity, SDLC phase/action type definitions, and exit code semantics. + +**Prerequisites:** None (first Issue to implement) + +**Tasks:** + +| Task ID | Description | Files | Effort | +|---------|-------------|-------|--------| +| T-1.1 | Define SdlcPhase, SdlcAction string constants and RoutingDecision record | `src/Polyphony/Routing/SdlcPhase.cs`, `SdlcAction.cs`, `RoutingDecision.cs` | 1-2 hours | +| T-1.2 | Define ExitCodes constants | `src/Polyphony/ExitCodes.cs` | 0.5 hours | +| T-1.3 | Create TwigCacheLocator to find `.twig/cache.db` | `src/Polyphony/Infrastructure/TwigCacheLocator.cs`, `tests/.../TwigCacheLocatorTests.cs` | 1-2 hours | +| T-1.4 | Create PolyphonyServiceRegistration and wire DI in Program.cs | `src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs`, `src/Polyphony/Program.cs` | 2-3 hours | +| T-1.5 | Create test fixtures (WorkItemBuilder, ProcessConfigBuilder) | `tests/.../TestFixtures/WorkItemBuilder.cs`, `ProcessConfigBuilder.cs` | 1-2 hours | + +**Acceptance Criteria:** +- [ ] `dotnet build` succeeds with zero warnings +- [ ] DI container resolves `IWorkItemRepository`, `ProcessConfig`, and `PhaseDetector` +- [ ] `TwigCacheLocator` finds cache.db from CWD and from explicit `--twig-dir` +- [ ] `ExitCodes`, `SdlcPhase`, `SdlcAction` constants are defined and compile +- [ ] Test builders create valid `WorkItem` and `ProcessConfig` instances + +### Issue 2: Phase Detection and Routing Engine + +**Goal:** Implement the core state machine that determines SDLC phase and next action for any work item type, plus branch name resolution for workspace hints. + +**Prerequisites:** Issue 1 (DI, types, test fixtures) + +**Tasks:** + +| Task ID | Description | Files | Effort | +|---------|-------------|-------|--------| +| T-2.1 | Implement PhaseDetector with rules for all type/state/children combinations | `src/Polyphony/Routing/PhaseDetector.cs` | 3-4 hours | +| T-2.2 | Implement BranchNameResolver for workspace hint generation | `src/Polyphony/Routing/BranchNameResolver.cs`, `tests/.../BranchNameResolverTests.cs` | 1-2 hours | +| T-2.3 | Add comprehensive PhaseDetector unit tests (all paths in the rules table) | `tests/.../Routing/PhaseDetectorTests.cs` | 3-4 hours | +| T-2.4 | Update PolyphonyJsonContext with RoutingDecision type | `src/Polyphony/PolyphonyJsonContext.cs` | 0.5 hours | + +**Acceptance Criteria:** +- [ ] PhaseDetector correctly classifies all 8+ phase/action combinations from the rules table +- [ ] Epic with no children → `needs_planning` / `plan` +- [ ] Epic with Proposed children → `ready_for_implementation` / `implement` +- [ ] Epic with all Done children → `ready_for_completion` / `close` +- [ ] Task in Proposed → `ready_for_implementation` / `implement` +- [ ] BranchNameResolver correctly substitutes `{root_id}` and `{slug}` in templates +- [ ] All tests pass with `dotnet test` + +### Issue 3: Command Implementations + +**Goal:** Wire the routing engine into all three CLI commands with proper error handling, exit codes, and structured JSON output. + +**Prerequisites:** Issue 2 (PhaseDetector, TransitionValidator, HierarchyWalker) + +**Tasks:** + +| Task ID | Description | Files | Effort | +|---------|-------------|-------|--------| +| T-3.1 | Implement TransitionValidator with precondition checking | `src/Polyphony/Routing/TransitionValidator.cs`, `tests/.../TransitionValidatorTests.cs` | 2-3 hours | +| T-3.2 | Implement HierarchyWalker with recursive traversal | `src/Polyphony/Routing/HierarchyWalker.cs`, `tests/.../HierarchyWalkerTests.cs` | 2-3 hours | +| T-3.3 | Rewrite RouteCommand with full routing logic and DI | `src/Polyphony/Commands/RouteCommand.cs` | 1-2 hours | +| T-3.4 | Rewrite ValidateCommand with TransitionValidator and DI | `src/Polyphony/Commands/ValidateCommand.cs` | 1-2 hours | +| T-3.5 | Rewrite HierarchyCommand with HierarchyWalker and DI | `src/Polyphony/Commands/HierarchyCommand.cs` | 1-2 hours | +| T-3.6 | Add end-to-end command tests with in-memory SQLite | `tests/.../Commands/RouteCommandTests.cs`, `ValidateCommandTests.cs`, `HierarchyCommandTests.cs` | 3-4 hours | + +**Acceptance Criteria:** +- [ ] `polyphony route --work-item 1234` outputs valid JSON with phase, action, and workspace_hint +- [ ] `polyphony validate --work-item 1234 --event begin_planning` outputs valid JSON with is_valid and target_state +- [ ] `polyphony hierarchy --work-item 1234 --depth 3` outputs valid JSON tree with capabilities per node +- [ ] All commands return appropriate exit codes (0 on success, 1-3 on errors) +- [ ] Missing work item returns exit code 3 with error JSON +- [ ] Invalid config path returns exit code 2 with error JSON +- [ ] `dotnet build` succeeds, `dotnet test` passes all tests +- [ ] `dotnet publish` with AOT succeeds without trim warnings + +--- + +## PR Groups + +### PG-1: Core Infrastructure and Routing Types + +**Scope:** Issue 1 (T-1.1 through T-1.5) +**Classification:** Deep — foundational DI and infrastructure setup +**Estimated LoC:** ~400 +**Files:** ~10 + +**What's in this PR:** +- SDLC phase/action constants, RoutingDecision record, ExitCodes +- TwigCacheLocator +- PolyphonyServiceRegistration (DI wiring) +- Program.cs DI setup +- Test fixtures (builders) +- Unit tests for TwigCacheLocator + +**Reviewability:** Self-contained foundation. No existing behavior changes — all new files plus minimal Program.cs modification. Reviewer can verify DI wiring and type definitions independently. + +**Successors:** PG-2 + +### PG-2: Phase Detection Engine + +**Scope:** Issue 2 (T-2.1 through T-2.4) +**Classification:** Deep — core algorithmic logic +**Estimated LoC:** ~500 +**Files:** ~5 + +**What's in this PR:** +- PhaseDetector implementation (the core state machine) +- BranchNameResolver +- Comprehensive PhaseDetector unit tests (all rule paths) +- BranchNameResolver unit tests +- PolyphonyJsonContext updates + +**Reviewability:** The heart of Polyphony. Reviewer should focus on the phase detection rules table and verify each test case maps to a real SDLC scenario. Pure logic — no I/O or side effects. + +**Predecessors:** PG-1 +**Successors:** PG-3 + +### PG-3: Command Implementations + +**Scope:** Issue 3 (T-3.1 through T-3.6) +**Classification:** Deep — integration of all components +**Estimated LoC:** ~700 +**Files:** ~12 + +**What's in this PR:** +- TransitionValidator with preconditions +- HierarchyWalker with recursive traversal +- RouteCommand, ValidateCommand, HierarchyCommand rewrites +- End-to-end command tests +- TransitionValidator and HierarchyWalker unit tests + +**Reviewability:** Largest PR but structurally straightforward — each command follows the same pattern (resolve services → load data → compute → serialize). Reviewer can evaluate each command independently. + +**Predecessors:** PG-2 + +--- + +## Execution Plan + +### PR Group Table + +| Group | Name | Issues/Tasks | Dependencies | Type | +|-------|------|-------------|--------------|------| +| PG-1 | Core Infrastructure and Routing Types | Issue 1: T-1.1, T-1.2, T-1.3, T-1.4, T-1.5 | None | deep | +| PG-2 | Phase Detection Engine | Issue 2: T-2.1, T-2.2, T-2.3, T-2.4 | PG-1 | deep | +| PG-3 | Command Implementations | Issue 3: T-3.1, T-3.2, T-3.3, T-3.4, T-3.5, T-3.6 | PG-2 | deep | + +### Execution Order + +**PG-1 → PG-2 → PG-3** (strictly sequential) + +1. **PG-1** establishes the foundational layer: SDLC type constants, exit codes, DI wiring, twig cache locator, test fixtures. All new files plus minimal `Program.cs` change. No existing behavior modified. Once merged, the project builds with DI configured and all type definitions in place. + +2. **PG-2** builds the core algorithmic heart: `PhaseDetector` state machine and `BranchNameResolver`. Depends only on types from PG-1. Pure logic with comprehensive unit tests covering every rule-table path. No I/O or command changes — the routing engine exists independently of the commands. + +3. **PG-3** integrates everything into the CLI: `TransitionValidator`, `HierarchyWalker`, and all three command rewrites. Also includes end-to-end command tests using in-memory SQLite. The AOT publish target is validated here as the final gate. + +### Validation Strategy per PG + +**PG-1 Validation:** +- `dotnet build` with zero warnings (TreatWarningsAsErrors) +- `dotnet test` — `TwigCacheLocatorTests` pass +- DI container resolves without exceptions in a smoke test +- `ExitCodes`, `SdlcPhase`, `SdlcAction` constants compile and are accessible + +**PG-2 Validation:** +- `dotnet build` with zero warnings +- `dotnet test` — `PhaseDetectorTests` cover all 8+ rows of the phase detection rules table; `BranchNameResolverTests` verify template substitution +- No command behavior changes (route/validate/hierarchy still return stubs — that's expected) + +**PG-3 Validation:** +- `dotnet build` with zero warnings +- `dotnet test` — `TransitionValidatorTests`, `HierarchyWalkerTests`, and end-to-end `RouteCommandTests`/`ValidateCommandTests`/`HierarchyCommandTests` all pass +- `dotnet publish` with AOT succeeds with no trim warnings +- Smoke-test outputs: valid JSON from all three commands against a real twig cache + +--- + +## References + +- [Conductor Design Principles](../../.github/skills/conductor-design/SKILL.md) — Especially P5 (type-agnostic), P8 (scripts over agents) +- [Twig SDLC Workflow](../../.github/skills/twig-sdlc/SKILL.md) — Full workflow definition +- [Process Config](../../.conductor/process-config.yaml) — Type capabilities, transitions, branch strategy +- [Work Item Type Definitions](../../.conductor/work-item-types/) — Epic, Issue, Task definitions diff --git a/src/Polyphony/Commands/HierarchyCommand.cs b/src/Polyphony/Commands/HierarchyCommand.cs index 27de3844..3cbbb21b 100644 --- a/src/Polyphony/Commands/HierarchyCommand.cs +++ b/src/Polyphony/Commands/HierarchyCommand.cs @@ -1,12 +1,13 @@ using System.Text.Json; using ConsoleAppFramework; +using Polyphony.Routing; namespace Polyphony.Commands; /// /// Outputs the work item hierarchy with role annotations. /// -public sealed class HierarchyCommand +public sealed class HierarchyCommand(HierarchyWalker walker) { /// /// Output the work item hierarchy with role annotations. @@ -15,20 +16,34 @@ public sealed class HierarchyCommand /// Maximum depth to traverse /// Path to .conductor/process-config.yaml [Command("hierarchy")] - public int Hierarchy(int workItem, int depth = 3, string config = ".conductor/process-config.yaml") + public async Task Hierarchy(int workItem, int depth = 3, string config = ".conductor/process-config.yaml", CancellationToken ct = default) { - // TODO: Phase 1 implementation - var result = new HierarchyResult + var result = await walker.WalkAsync(workItem, depth, ct); + + if (result is null) + { + Console.WriteLine($$"""{"error":"Work item {{workItem}} not found","work_item_id":{{workItem}}}"""); + return ExitCodes.CacheError; + } + + var normalized = EnsureChildrenArrays(result); + Console.WriteLine(JsonSerializer.Serialize(normalized, PolyphonyJsonContext.Default.HierarchyResult)); + return ExitCodes.Success; + } + + /// + /// Recursively replaces null Children with empty arrays so the JSON output + /// always contains a "children" field per node. + /// + private static HierarchyResult EnsureChildrenArrays(HierarchyResult node) + { + var children = node.Children ?? []; + var normalized = new HierarchyResult[children.Length]; + for (var i = 0; i < children.Length; i++) { - WorkItemId = workItem, - Title = "Not yet implemented", - Type = "Unknown", - Capabilities = [], - State = "Unknown", - Children = null - }; + normalized[i] = EnsureChildrenArrays(children[i]); + } - Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.HierarchyResult)); - return 0; + return node with { Children = normalized }; } } diff --git a/src/Polyphony/Commands/RouteCommand.cs b/src/Polyphony/Commands/RouteCommand.cs index 91c52736..60518b42 100644 --- a/src/Polyphony/Commands/RouteCommand.cs +++ b/src/Polyphony/Commands/RouteCommand.cs @@ -1,12 +1,18 @@ using System.Text.Json; using ConsoleAppFramework; +using Polyphony.Configuration; +using Polyphony.Routing; +using Twig.Domain.Interfaces; namespace Polyphony.Commands; /// /// Determines the current SDLC phase and next action for a work item. /// -public sealed class RouteCommand +public sealed class RouteCommand( + PhaseDetector phaseDetector, + IWorkItemRepository repository, + ProcessConfig processConfig) { /// /// Route a work item through the SDLC state machine. @@ -14,24 +20,29 @@ public sealed class RouteCommand /// ADO work item ID /// Path to .conductor/process-config.yaml [Command("route")] - public int Route(int workItem, string config = ".conductor/process-config.yaml") + public async Task Route(int workItem, string config = ".conductor/process-config.yaml", CancellationToken ct = default) { - // TODO: Phase 1 implementation - // 1. Verify freshness (C1) - // 2. Load process config - // 3. Load work item hierarchy from twig cache - // 4. Determine phase via state machine - // 5. Output routing decision + var item = await repository.GetByIdAsync(workItem, ct); + if (item is null) + { + Console.WriteLine($$"""{"error":"Work item {{workItem}} not found","work_item_id":{{workItem}}}"""); + return ExitCodes.CacheError; + } + + var children = await repository.GetChildrenAsync(workItem, ct); + var decision = phaseDetector.Detect(item, children); + var workspaceHint = BranchNameResolver.Resolve(processConfig, item); var result = new RouteResult { WorkItemId = workItem, - Phase = "not_implemented", - Action = "none", - Message = "Polyphony routing engine not yet implemented. This is a Phase 1 deliverable." + Phase = decision.Phase, + Action = decision.Action, + Message = decision.Message, + WorkspaceHint = workspaceHint, }; Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.RouteResult)); - return 0; + return ExitCodes.Success; } } diff --git a/src/Polyphony/Commands/ValidateCommand.cs b/src/Polyphony/Commands/ValidateCommand.cs index dec6b862..496c2317 100644 --- a/src/Polyphony/Commands/ValidateCommand.cs +++ b/src/Polyphony/Commands/ValidateCommand.cs @@ -1,12 +1,16 @@ using System.Text.Json; using ConsoleAppFramework; +using Polyphony.Routing; +using Twig.Domain.Interfaces; namespace Polyphony.Commands; /// /// Validates that a lifecycle event transition is legal for a work item. /// -public sealed class ValidateCommand +public sealed class ValidateCommand( + TransitionValidator validator, + IWorkItemRepository repository) { /// /// Validate that a lifecycle event transition is legal for a work item. @@ -15,18 +19,19 @@ public sealed class ValidateCommand /// Lifecycle event name (e.g., begin_planning, implementation_complete) /// Path to .conductor/process-config.yaml [Command("validate")] - public int Validate(int workItem, string @event, string config = ".conductor/process-config.yaml") + public async Task Validate(int workItem, string @event, string config = ".conductor/process-config.yaml", CancellationToken ct = default) { - // TODO: Phase 1 implementation - var result = new ValidateResult + var item = await repository.GetByIdAsync(workItem, ct); + if (item is null) { - WorkItemId = workItem, - Event = @event, - IsValid = false, - Message = "Validation not yet implemented. This is a Phase 1 deliverable." - }; + Console.WriteLine($$"""{"error":"Work item {{workItem}} not found","work_item_id":{{workItem}}}"""); + return ExitCodes.CacheError; + } + + var children = await repository.GetChildrenAsync(workItem, ct); + var result = validator.Validate(item, @event, children); Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.ValidateResult)); - return 0; + return result.IsValid ? ExitCodes.Success : ExitCodes.RoutingFailure; } } diff --git a/src/Polyphony/ExitCodes.cs b/src/Polyphony/ExitCodes.cs new file mode 100644 index 00000000..2ffdc751 --- /dev/null +++ b/src/Polyphony/ExitCodes.cs @@ -0,0 +1,29 @@ +namespace Polyphony; + +/// +/// Exit code constants used by all Polyphony CLI commands. +/// Conductor shell scripts branch on these codes to route workflow control. +/// +public static class ExitCodes +{ + /// + /// Command completed successfully; JSON output is valid. + /// + public const int Success = 0; + + /// + /// Routing or validation logic determined an invalid state + /// (e.g., invalid lifecycle event, illegal state transition). + /// + public const int RoutingFailure = 1; + + /// + /// Process config file is missing, malformed, or invalid. + /// + public const int ConfigError = 2; + + /// + /// Twig SQLite cache is inaccessible or the requested work item is not found. + /// + public const int CacheError = 3; +} diff --git a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs new file mode 100644 index 00000000..96c4bd06 --- /dev/null +++ b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using Polyphony.Configuration; +using Polyphony.Routing; +using Twig.Infrastructure; + +namespace Polyphony.Infrastructure; + +/// +/// Registers Polyphony services into an . +/// Layers Polyphony-specific registrations on top of twig core data-access services. +/// +public static class PolyphonyServiceRegistration +{ + /// + /// Registers all services needed by Polyphony CLI commands. + /// Delegates to for + /// data-access services (repositories, cache store, paths), then adds + /// Polyphony-specific services (ProcessConfig, and in later issues: PhaseDetector, + /// TransitionValidator, HierarchyWalker). + /// + /// The service collection. + /// Path to the process-config.yaml file. + /// Optional explicit path to the .twig directory. + /// When null, falls back + /// to CWD/.twig. + public static IServiceCollection AddPolyphonyServices( + this IServiceCollection services, + string configPath, + string? twigDir = null) + { + // Twig core services: TwigPaths, SqliteCacheStore, IWorkItemRepository, + // IProcessTypeStore, IContextStore, and other data-access infrastructure. + services.AddTwigCoreServices(twigDir: twigDir); + + // Polyphony-specific: ProcessConfig loaded from YAML. + // Registered as a factory so loading is deferred until first resolution. + services.AddSingleton(_ => ProcessConfigLoader.Load(configPath)); + + // Routing services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Polyphony/Infrastructure/TwigCacheLocator.cs b/src/Polyphony/Infrastructure/TwigCacheLocator.cs new file mode 100644 index 00000000..e269e884 --- /dev/null +++ b/src/Polyphony/Infrastructure/TwigCacheLocator.cs @@ -0,0 +1,78 @@ +namespace Polyphony.Infrastructure; + +/// +/// Locates the .twig/ directory from either an explicit path or by walking up +/// from the current working directory. The resolved directory path is passed to +/// +/// which handles actual DB path resolution (multi-context layout). +/// +public static class TwigCacheLocator +{ + private const string TwigDirName = ".twig"; + + /// + /// Resolves the path to the .twig/ directory. + /// + /// Optional explicit path. May point directly to a .twig/ + /// directory or to a parent directory that contains one. When null, walks up + /// from (or CWD) looking for a .twig/ directory. + /// Directory to start the walk-up search from when + /// is null. Defaults to + /// . + /// Full path to the resolved .twig/ directory. + /// + /// Thrown when the .twig/ directory cannot be found. + /// Callers typically map this to exit code 3. + /// + public static string ResolveTwigDir(string? twigDir, string? startDir = null) + { + return twigDir is not null + ? ResolveExplicit(twigDir) + : DiscoverFromAncestors(startDir ?? Directory.GetCurrentDirectory()); + } + + private static string ResolveExplicit(string path) + { + var fullPath = Path.GetFullPath(path); + + if (!Directory.Exists(fullPath)) + { + throw new DirectoryNotFoundException( + $"The specified twig directory '{path}' does not exist."); + } + + // The path IS the .twig/ directory + if (IsTwigDirName(fullPath)) + return fullPath; + + // The path CONTAINS a .twig/ subdirectory + var candidate = Path.Combine(fullPath, TwigDirName); + if (Directory.Exists(candidate)) + return candidate; + + throw new DirectoryNotFoundException( + $"The specified path '{path}' does not contain a {TwigDirName}/ directory."); + } + + private static string DiscoverFromAncestors(string startDir) + { + var current = Path.GetFullPath(startDir); + + while (current is not null) + { + var candidate = Path.Combine(current, TwigDirName); + if (Directory.Exists(candidate)) + return candidate; + + current = Directory.GetParent(current)?.FullName; + } + + throw new DirectoryNotFoundException( + $"Could not locate a {TwigDirName}/ directory. " + + "Searched from the current directory up to the filesystem root. " + + "Use --twig-dir to specify the path explicitly."); + } + + private static bool IsTwigDirName(string path) => + Path.GetFileName(path).Equals(TwigDirName, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Polyphony/Program.cs b/src/Polyphony/Program.cs index 4e17edb1..26a1c718 100644 --- a/src/Polyphony/Program.cs +++ b/src/Polyphony/Program.cs @@ -1,7 +1,20 @@ using ConsoleAppFramework; using Polyphony.Commands; +using Polyphony.Infrastructure; +using Twig.Infrastructure.Config; + +SQLitePCL.Batteries.Init(); + +var app = ConsoleApp.Create() + .ConfigureServices(services => + { + var twigDir = WorkspaceDiscovery.FindTwigDir() + ?? Path.Combine(Directory.GetCurrentDirectory(), ".twig"); + var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".conductor", "process-config.yaml"); + + services.AddPolyphonyServices(configPath, twigDir); + }); -var app = ConsoleApp.Create(); app.Add(); app.Add(); app.Add(); diff --git a/src/Polyphony/Routing/BranchNameResolver.cs b/src/Polyphony/Routing/BranchNameResolver.cs new file mode 100644 index 00000000..194a12d2 --- /dev/null +++ b/src/Polyphony/Routing/BranchNameResolver.cs @@ -0,0 +1,88 @@ +using Polyphony.Configuration; +using Twig.Domain.Aggregates; + +namespace Polyphony.Routing; + +/// +/// Resolves branch name templates from +/// by substituting placeholders with work item metadata. +/// +public static class BranchNameResolver +{ + /// + /// Resolves branch names for the given work item using the branch strategy templates. + /// Substitutes {id} with the root item's ID and {slug} with a URL-safe + /// slug derived from the item title. + /// + /// The process config containing branch strategy templates. + /// The root work item whose metadata drives branch naming. + /// A with resolved branch names, or null if no branch strategy is configured. + public static WorkspaceHint? Resolve(ProcessConfig config, WorkItem rootItem) + { + if (config.BranchStrategy is null) + return null; + + var slug = Slugify(rootItem.Title); + + return new WorkspaceHint + { + FeatureBranch = SubstitutePlaceholders(config.BranchStrategy.FeatureBranch, rootItem.Id, slug), + PgBranch = SubstitutePlaceholders(config.BranchStrategy.PgBranch, rootItem.Id, slug), + }; + } + + private static string SubstitutePlaceholders(string template, int id, string slug) + { + if (string.IsNullOrEmpty(template)) + return ""; + + return template + .Replace("{id}", id.ToString(), StringComparison.OrdinalIgnoreCase) + .Replace("{root_id}", id.ToString(), StringComparison.OrdinalIgnoreCase) + .Replace("{slug}", slug, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Converts a title string to a URL-safe slug: lowercase, alphanumeric and hyphens only, + /// truncated to 50 characters. + /// + public static string Slugify(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return ""; + + var chars = new char[title.Length]; + var len = 0; + var lastWasHyphen = true; // prevents leading hyphen + + for (var i = 0; i < title.Length; i++) + { + var c = title[i]; + + if (char.IsLetterOrDigit(c)) + { + chars[len++] = char.ToLowerInvariant(c); + lastWasHyphen = false; + } + else if (!lastWasHyphen) + { + chars[len++] = '-'; + lastWasHyphen = true; + } + } + + // Trim trailing hyphen + if (len > 0 && chars[len - 1] == '-') + len--; + + // Truncate to 50 chars + if (len > 50) + len = 50; + + // Trim trailing hyphen after truncation + if (len > 0 && chars[len - 1] == '-') + len--; + + return new string(chars, 0, len); + } +} diff --git a/src/Polyphony/Routing/HierarchyWalker.cs b/src/Polyphony/Routing/HierarchyWalker.cs new file mode 100644 index 00000000..efbe85ac --- /dev/null +++ b/src/Polyphony/Routing/HierarchyWalker.cs @@ -0,0 +1,74 @@ +using Polyphony.Configuration; +using Twig.Domain.Interfaces; + +namespace Polyphony.Routing; + +/// +/// Recursively traverses the work item tree to a specified depth, +/// annotating each node with capabilities from the process config. +/// +public sealed class HierarchyWalker(ProcessConfig processConfig, IWorkItemRepository repository) +{ + /// + /// Walks the hierarchy starting from down to levels. + /// Each node is annotated with capabilities from . + /// + /// The work item ID to start from. + /// Maximum depth to traverse. 0 returns only the root node. + /// Cancellation token. + /// A tree, or null if the root item is not found. + public async Task WalkAsync(int rootId, int maxDepth, CancellationToken ct) + { + var root = await repository.GetByIdAsync(rootId, ct); + if (root is null) + return null; + + return await BuildNodeAsync(root, maxDepth, currentDepth: 0, ct); + } + + private async Task BuildNodeAsync( + Twig.Domain.Aggregates.WorkItem item, + int maxDepth, + int currentDepth, + CancellationToken ct) + { + var typeName = item.Type.Value; + var capabilities = LookupCapabilities(typeName); + + HierarchyResult[]? children = null; + + if (currentDepth < maxDepth) + { + var childItems = await repository.GetChildrenAsync(item.Id, ct); + + if (childItems.Count > 0) + { + var childResults = new HierarchyResult[childItems.Count]; + for (var i = 0; i < childItems.Count; i++) + { + childResults[i] = await BuildNodeAsync(childItems[i], maxDepth, currentDepth + 1, ct); + } + + children = childResults; + } + } + + return new HierarchyResult + { + WorkItemId = item.Id, + Title = item.Title, + Type = typeName, + Capabilities = capabilities, + State = item.State, + Children = children, + }; + } + + private string[] LookupCapabilities(string typeName) + { + if (processConfig.Types.TryGetValue(typeName, out var typeConfig)) + return typeConfig.Capabilities; + + return []; + } +} diff --git a/src/Polyphony/Routing/PhaseDetector.cs b/src/Polyphony/Routing/PhaseDetector.cs new file mode 100644 index 00000000..8873248b --- /dev/null +++ b/src/Polyphony/Routing/PhaseDetector.cs @@ -0,0 +1,184 @@ +using Polyphony.Configuration; +using Twig.Domain.Aggregates; +using Twig.Domain.Enums; +using Twig.Domain.Services.Process; + +namespace Polyphony.Routing; + +/// +/// Core state machine that determines the SDLC phase and next action +/// for a work item based on its type, capabilities, state, and children. +/// +public sealed class PhaseDetector(ProcessConfig processConfig) +{ + /// + /// Determines the SDLC phase and recommended action for the given work item. + /// + /// The work item to evaluate. + /// The immediate children of the work item. + /// A describing the detected phase and action. + public RoutingDecision Detect(WorkItem item, IReadOnlyList children) + { + var category = StateCategoryResolver.Resolve(item.State, entries: null); + + // Terminal states apply regardless of type or capabilities + if (category == StateCategory.Completed) + return new RoutingDecision { Phase = SdlcPhase.Done, Action = SdlcAction.None, Message = "Work item is complete." }; + + if (category == StateCategory.Removed) + return new RoutingDecision { Phase = SdlcPhase.Removed, Action = SdlcAction.None, Message = "Work item has been removed." }; + + var capabilities = LookupCapabilities(item.Type.Value); + var isPlannable = Array.Exists(capabilities, c => string.Equals(c, "plannable", StringComparison.OrdinalIgnoreCase)); + var isImplementable = Array.Exists(capabilities, c => string.Equals(c, "implementable", StringComparison.OrdinalIgnoreCase)); + + if (isPlannable) + return DetectPlannablePhase(item, children, category, isImplementable); + + if (isImplementable) + return DetectImplementablePhase(category); + + // Unknown capability set — fall through to unknown + return new RoutingDecision + { + Phase = SdlcPhase.Unknown, + Action = SdlcAction.None, + Message = $"No recognized capabilities for type '{item.Type.Value}'.", + }; + } + + private static RoutingDecision DetectPlannablePhase( + WorkItem item, + IReadOnlyList children, + StateCategory category, + bool isAlsoImplementable) + { + if (category == StateCategory.Proposed) + { + return new RoutingDecision + { + Phase = SdlcPhase.NeedsPlanning, + Action = SdlcAction.Plan, + Message = $"{item.Type.Value} '{item.Title}' is in Proposed state and needs planning.", + }; + } + + if (category == StateCategory.InProgress || category == StateCategory.Resolved) + { + if (children.Count == 0) + { + // Plannable + implementable with no children in InProgress → ready for direct implementation + if (isAlsoImplementable) + { + return new RoutingDecision + { + Phase = SdlcPhase.ReadyForImplementation, + Action = SdlcAction.Implement, + Message = $"{item.Type.Value} '{item.Title}' is in progress with no children — ready for direct implementation.", + }; + } + + // Plannable-only with no children → needs seeding (decomposition) + return new RoutingDecision + { + Phase = SdlcPhase.NeedsSeeding, + Action = SdlcAction.Seed, + Message = $"{item.Type.Value} '{item.Title}' is in progress but has no children — needs seeding.", + }; + } + + return ClassifyByChildren(item, children); + } + + return new RoutingDecision + { + Phase = SdlcPhase.Unknown, + Action = SdlcAction.None, + Message = $"Unrecognized state category '{category}' for plannable type '{item.Type.Value}'.", + }; + } + + private static RoutingDecision DetectImplementablePhase(StateCategory category) + { + return category switch + { + StateCategory.Proposed => new RoutingDecision + { + Phase = SdlcPhase.ReadyForImplementation, + Action = SdlcAction.Implement, + Message = "Implementable item is in Proposed state — ready for implementation.", + }, + StateCategory.InProgress => new RoutingDecision + { + Phase = SdlcPhase.InProgress, + Action = SdlcAction.Monitor, + Message = "Implementable item is in progress.", + }, + StateCategory.Resolved => new RoutingDecision + { + Phase = SdlcPhase.InProgress, + Action = SdlcAction.Monitor, + Message = "Implementable item is resolved, awaiting completion.", + }, + _ => new RoutingDecision + { + Phase = SdlcPhase.Unknown, + Action = SdlcAction.None, + Message = $"Unrecognized state category '{category}' for implementable type.", + }, + }; + } + + private static RoutingDecision ClassifyByChildren(WorkItem item, IReadOnlyList children) + { + var allCompleted = true; + var allProposed = true; + + for (var i = 0; i < children.Count; i++) + { + var childCategory = StateCategoryResolver.Resolve(children[i].State, entries: null); + + if (childCategory != StateCategory.Completed && childCategory != StateCategory.Removed) + allCompleted = false; + + if (childCategory != StateCategory.Proposed) + allProposed = false; + } + + if (allCompleted) + { + return new RoutingDecision + { + Phase = SdlcPhase.ReadyForCompletion, + Action = SdlcAction.Close, + Message = $"All children of {item.Type.Value} '{item.Title}' are complete — ready for close-out.", + }; + } + + if (allProposed) + { + return new RoutingDecision + { + Phase = SdlcPhase.ReadyForImplementation, + Action = SdlcAction.Implement, + Message = $"All children of {item.Type.Value} '{item.Title}' are in Proposed state — ready for implementation.", + }; + } + + // Mixed states — work is in progress + return new RoutingDecision + { + Phase = SdlcPhase.InProgress, + Action = SdlcAction.Monitor, + Message = $"{item.Type.Value} '{item.Title}' has children in mixed states — monitoring progress.", + }; + } + + private string[] LookupCapabilities(string typeName) + { + if (processConfig.Types.TryGetValue(typeName, out var typeConfig)) + return typeConfig.Capabilities; + + return []; + } +} diff --git a/src/Polyphony/Routing/RoutingDecision.cs b/src/Polyphony/Routing/RoutingDecision.cs new file mode 100644 index 00000000..55ba27c7 --- /dev/null +++ b/src/Polyphony/Routing/RoutingDecision.cs @@ -0,0 +1,23 @@ +namespace Polyphony.Routing; + +/// +/// The result of phase detection: which SDLC phase a work item is in +/// and what action the conductor should take next. +/// +public sealed record RoutingDecision +{ + /// + /// The detected SDLC phase. One of the constants. + /// + public required string Phase { get; init; } + + /// + /// The recommended action. One of the constants. + /// + public required string Action { get; init; } + + /// + /// Optional human-readable message explaining the routing decision. + /// + public string? Message { get; init; } +} diff --git a/src/Polyphony/Routing/SdlcAction.cs b/src/Polyphony/Routing/SdlcAction.cs new file mode 100644 index 00000000..ab3dc1bb --- /dev/null +++ b/src/Polyphony/Routing/SdlcAction.cs @@ -0,0 +1,15 @@ +namespace Polyphony.Routing; + +/// +/// SDLC action string constants used by the routing engine. +/// String constants (not enums) for AOT compatibility and forward extensibility. +/// +public static class SdlcAction +{ + public const string Plan = "plan"; + public const string Seed = "seed"; + public const string Implement = "implement"; + public const string Monitor = "monitor"; + public const string Close = "close"; + public const string None = "none"; +} diff --git a/src/Polyphony/Routing/SdlcPhase.cs b/src/Polyphony/Routing/SdlcPhase.cs new file mode 100644 index 00000000..718c74f9 --- /dev/null +++ b/src/Polyphony/Routing/SdlcPhase.cs @@ -0,0 +1,17 @@ +namespace Polyphony.Routing; + +/// +/// SDLC phase string constants used by the routing engine. +/// String constants (not enums) for AOT compatibility and forward extensibility. +/// +public static class SdlcPhase +{ + public const string NeedsPlanning = "needs_planning"; + public const string NeedsSeeding = "needs_seeding"; + public const string ReadyForImplementation = "ready_for_implementation"; + public const string InProgress = "in_progress"; + public const string ReadyForCompletion = "ready_for_completion"; + public const string Done = "done"; + public const string Removed = "removed"; + public const string Unknown = "unknown"; +} diff --git a/src/Polyphony/Routing/TransitionValidator.cs b/src/Polyphony/Routing/TransitionValidator.cs new file mode 100644 index 00000000..8827dccd --- /dev/null +++ b/src/Polyphony/Routing/TransitionValidator.cs @@ -0,0 +1,139 @@ +using Polyphony.Configuration; +using Twig.Domain.Aggregates; +using Twig.Domain.Enums; +using Twig.Domain.Services.Process; + +namespace Polyphony.Routing; + +/// +/// Validates whether a lifecycle event transition is legal for a work item +/// given its current state and the process config transition table. +/// +public sealed class TransitionValidator(ProcessConfig processConfig) +{ + /// + /// Validates that the given is a legal transition + /// for the specified and its . + /// + /// The work item to validate against. + /// The lifecycle event name (e.g., begin_planning, implementation_complete). + /// The immediate children of the work item. + /// A indicating validity, target state, and any messages. + public ValidateResult Validate(WorkItem item, string eventName, IReadOnlyList children) + { + var typeName = item.Type.Value; + + // Step 1: Look up transitions for this work item type + if (!processConfig.Transitions.TryGetValue(typeName, out var typeTransitions)) + { + return new ValidateResult + { + WorkItemId = item.Id, + Event = eventName, + IsValid = false, + Message = $"No transitions defined for work item type '{typeName}'.", + }; + } + + // Step 2: Look up the specific event + if (!typeTransitions.TryGetValue(eventName, out var targetState)) + { + return new ValidateResult + { + WorkItemId = item.Id, + Event = eventName, + IsValid = false, + Message = $"Unknown event '{eventName}' for work item type '{typeName}'.", + }; + } + + // Step 3: Check preconditions based on event name + var preconditionMessage = CheckPrecondition(eventName, item, children); + if (preconditionMessage is not null) + { + return new ValidateResult + { + WorkItemId = item.Id, + Event = eventName, + IsValid = false, + TargetState = targetState, + Message = preconditionMessage, + }; + } + + // Step 4: Valid transition + return new ValidateResult + { + WorkItemId = item.Id, + Event = eventName, + IsValid = true, + TargetState = targetState, + Message = $"Transition '{eventName}' is valid. Target state: '{targetState}'.", + }; + } + + private static string? CheckPrecondition(string eventName, WorkItem item, IReadOnlyList children) + { + var itemCategory = StateCategoryResolver.Resolve(item.State, entries: null); + + return eventName switch + { + "all_children_complete" => CheckAllChildrenComplete(children), + "begin_planning" => CheckBeginPlanning(itemCategory), + "begin_implementation" => CheckBeginImplementation(itemCategory), + "implementation_complete" => CheckImplementationComplete(itemCategory), + _ => null, // No precondition for unknown events + }; + } + + private static string? CheckAllChildrenComplete(IReadOnlyList children) + { + if (children.Count == 0) + return "Precondition 'all_children_complete' failed: work item has no children."; + + for (var i = 0; i < children.Count; i++) + { + var childCategory = StateCategoryResolver.Resolve(children[i].State, entries: null); + if (childCategory != StateCategory.Completed) + { + return $"Precondition 'all_children_complete' failed: child #{children[i].Id} " + + $"is in state '{children[i].State}' (category: {childCategory}), expected Completed."; + } + } + + return null; + } + + private static string? CheckBeginPlanning(StateCategory itemCategory) + { + if (itemCategory != StateCategory.Proposed) + { + return $"Precondition 'begin_planning' failed: item must be in Proposed state category, " + + $"but is in {itemCategory}."; + } + + return null; + } + + private static string? CheckBeginImplementation(StateCategory itemCategory) + { + if (itemCategory != StateCategory.Proposed && itemCategory != StateCategory.InProgress) + { + return $"Precondition 'begin_implementation' failed: item must be in Proposed or InProgress " + + $"state category, but is in {itemCategory}."; + } + + return null; + } + + private static string? CheckImplementationComplete(StateCategory itemCategory) + { + if (itemCategory != StateCategory.InProgress) + { + return $"Precondition 'implementation_complete' failed: item must be in InProgress state category, " + + $"but is in {itemCategory}."; + } + + return null; + } +} diff --git a/tests/Polyphony.Tests/Commands/CommandTestBase.cs b/tests/Polyphony.Tests/Commands/CommandTestBase.cs new file mode 100644 index 00000000..38161866 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/CommandTestBase.cs @@ -0,0 +1,132 @@ +using Polyphony.Configuration; +using Polyphony.Tests.TestFixtures; +using Twig.Domain.Aggregates; +using Twig.Domain.Services; +using Twig.Infrastructure.Persistence; + +namespace Polyphony.Tests.Commands; + +/// +/// Base class for end-to-end command tests. Provides an in-memory SQLite database +/// seeded with test work items, stdout capture, and a default . +/// Each test method gets a fresh database instance (xUnit creates a new class per test). +/// +public abstract class CommandTestBase : IDisposable +{ + private static readonly object ConsoleLock = new(); + private static readonly object InitLock = new(); + private static bool s_sqliteInitialized; + + protected SqliteCacheStore Store { get; } + protected SqliteWorkItemRepository Repository { get; } + protected ProcessConfig Config { get; } + + protected CommandTestBase() + { + EnsureSqliteInitialized(); + Store = new SqliteCacheStore("Data Source=:memory:"); + Repository = new SqliteWorkItemRepository(Store, new WorkItemMapper()); + Config = CreateDefaultConfig(); + } + + /// + /// Executes a synchronous command while capturing stdout. + /// Uses a lock to prevent parallel test interference with . + /// + protected static (int ExitCode, string Output) CaptureConsole(Func action) + { + lock (ConsoleLock) + { + using var writer = new StringWriter(); + var original = Console.Out; + Console.SetOut(writer); + try + { + var exitCode = action(); + return (exitCode, writer.ToString().Trim()); + } + finally + { + Console.SetOut(original); + } + } + } + + /// + /// Executes an asynchronous command while capturing stdout. + /// Uses a lock to prevent parallel test interference with . + /// + protected static async Task<(int ExitCode, string Output)> CaptureConsoleAsync(Func> action) + { + // Acquire the lock synchronously, then run the async action inside it. + // Safe because command methods are CPU-bound once the walker completes. + Monitor.Enter(ConsoleLock); + try + { + using var writer = new StringWriter(); + var original = Console.Out; + Console.SetOut(writer); + try + { + var exitCode = await action(); + return (exitCode, writer.ToString().Trim()); + } + finally + { + Console.SetOut(original); + } + } + finally + { + Monitor.Exit(ConsoleLock); + } + } + + /// + /// Seeds work items into the in-memory SQLite database. + /// + protected async Task SeedAsync(params WorkItem[] items) + { + foreach (var item in items) + await Repository.SaveAsync(item); + } + + private static ProcessConfig CreateDefaultConfig() + { + return new ProcessConfigBuilder() + .WithType("Epic", ["plannable"], new Dictionary + { + ["begin_planning"] = "Doing", + ["implementation_complete"] = "Done" + }) + .WithType("Issue", ["plannable", "implementable"], new Dictionary + { + ["begin_planning"] = "Doing", + ["implementation_complete"] = "Done" + }) + .WithType("Task", ["implementable"], new Dictionary + { + ["begin_implementation"] = "Doing", + ["implementation_complete"] = "Done" + }) + .WithBranchStrategy() + .Build(); + } + + private static void EnsureSqliteInitialized() + { + if (s_sqliteInitialized) return; + lock (InitLock) + { + if (s_sqliteInitialized) return; + SQLitePCL.Batteries.Init(); + s_sqliteInitialized = true; + } + } + + public void Dispose() + { + Store.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/tests/Polyphony.Tests/Commands/HierarchyCommandTests.cs b/tests/Polyphony.Tests/Commands/HierarchyCommandTests.cs new file mode 100644 index 00000000..eeee274d --- /dev/null +++ b/tests/Polyphony.Tests/Commands/HierarchyCommandTests.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Routing; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// End-to-end tests for using an in-memory SQLite database. +/// Tests verify JSON tree output, depth limits, capability annotations, error handling, +/// and that children arrays are never null in the output. +/// +public sealed class HierarchyCommandTests : CommandTestBase +{ + private HierarchyCommand CreateCommand() => new(new HierarchyWalker(Config, Repository)); + + [Fact] + public async Task Hierarchy_RootNotFound_ReturnsCacheErrorExitCode() + { + var cmd = CreateCommand(); + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.Hierarchy(999)); + + exitCode.ShouldBe(ExitCodes.CacheError); + } + + [Fact] + public async Task Hierarchy_RootNotFound_OutputsErrorJson() + { + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(999)); + + output.ShouldContain("\"error\""); + output.ShouldContain("\"work_item_id\":999"); + } + + [Fact] + public async Task Hierarchy_SingleItem_ReturnsSuccessWithEmptyChildren() + { + var epic = new WorkItemBuilder() + .WithId(100) + .WithType("Epic") + .WithTitle("Root Epic") + .WithState("Doing") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(100)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.HierarchyResult); + result.ShouldNotBeNull(); + result.WorkItemId.ShouldBe(100); + result.Title.ShouldBe("Root Epic"); + result.Type.ShouldBe("Epic"); + result.State.ShouldBe("Doing"); + result.Children.ShouldNotBeNull(); + result.Children.ShouldBeEmpty(); + } + + [Fact] + public async Task Hierarchy_SingleItem_AnnotatesCapabilities() + { + var epic = new WorkItemBuilder() + .WithId(100) + .WithType("Epic") + .WithTitle("Root Epic") + .WithState("Doing") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(100)); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.HierarchyResult); + result.ShouldNotBeNull(); + result.Capabilities.ShouldContain("plannable"); + } + + [Fact] + public async Task Hierarchy_TwoLevelTree_ChildrenIncludedInOutput() + { + var (epic, children) = new WorkItemBuilder() + .WithId(100) + .WithType("Epic") + .WithTitle("Parent Epic") + .WithState("Doing") + .WithChildren( + new WorkItemBuilder().WithId(201).WithType("Issue").WithTitle("Child Issue").WithState("To Do"), + new WorkItemBuilder().WithId(202).WithType("Task").WithTitle("Child Task").WithState("Doing")) + .BuildAll(); + await SeedAsync([epic, .. children]); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(100)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.HierarchyResult); + result.ShouldNotBeNull(); + result.WorkItemId.ShouldBe(100); + result.Children.ShouldNotBeNull(); + result.Children.Length.ShouldBe(2); + result.Children[0].WorkItemId.ShouldBe(201); + result.Children[1].WorkItemId.ShouldBe(202); + } + + [Fact] + public async Task Hierarchy_TwoLevelTree_LeafChildrenHaveEmptyArray() + { + var (epic, children) = new WorkItemBuilder() + .WithId(100) + .WithType("Epic") + .WithTitle("Parent Epic") + .WithState("Doing") + .WithChildren( + new WorkItemBuilder().WithId(201).WithType("Task").WithTitle("Leaf Task").WithState("To Do")) + .BuildAll(); + await SeedAsync([epic, .. children]); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(100)); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.HierarchyResult); + result.ShouldNotBeNull(); + result.Children.ShouldNotBeNull(); + result.Children[0].Children.ShouldNotBeNull(); + result.Children[0].Children.ShouldBeEmpty(); + } + + [Fact] + public async Task Hierarchy_DepthLimit_TruncatesAtSpecifiedDepth() + { + // 3-level tree: Epic → Issue → Task, but request depth=1 + var epic = new WorkItemBuilder() + .WithId(300).WithType("Epic").WithTitle("Depth Test Epic").WithState("Doing").Build(); + var issue = new WorkItemBuilder() + .WithId(301).WithType("Issue").WithTitle("Issue").WithState("Doing").WithParentId(300).Build(); + var task = new WorkItemBuilder() + .WithId(302).WithType("Task").WithTitle("Task").WithState("To Do").WithParentId(301).Build(); + await SeedAsync(epic, issue, task); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(300, depth: 1)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.HierarchyResult); + result.ShouldNotBeNull(); + result.Children.ShouldNotBeNull(); + result.Children!.Length.ShouldBe(1); + // At depth 1, the child's children should be an empty array (not traversed) + result.Children[0].Children.ShouldNotBeNull(); + result.Children[0].Children.ShouldBeEmpty(); + } + + [Fact] + public async Task Hierarchy_DefaultDepth_TraversesThreeLevels() + { + // 3-level tree: Epic → Issue → Task; default depth=3 should include all + var epic = new WorkItemBuilder() + .WithId(400).WithType("Epic").WithTitle("Full Tree").WithState("Doing").Build(); + var issue = new WorkItemBuilder() + .WithId(401).WithType("Issue").WithTitle("Issue").WithState("Doing").WithParentId(400).Build(); + var task = new WorkItemBuilder() + .WithId(402).WithType("Task").WithTitle("Task").WithState("To Do").WithParentId(401).Build(); + await SeedAsync(epic, issue, task); + + var cmd = CreateCommand(); + // depth not specified — should use default of 3 + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(400)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.HierarchyResult); + result.ShouldNotBeNull(); + result.Children.ShouldNotBeNull(); + result.Children![0].Children.ShouldNotBeNull(); + result.Children[0].Children![0].WorkItemId.ShouldBe(402); + } + + [Fact] + public async Task Hierarchy_OutputUsesSnakeCasePropertyNames() + { + var epic = new WorkItemBuilder() + .WithId(500) + .WithType("Epic") + .WithTitle("Snake Case Test") + .WithState("Doing") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Hierarchy(500)); + + output.ShouldContain("\"work_item_id\""); + output.ShouldContain("\"children\""); + output.ShouldContain("\"capabilities\""); + } +} diff --git a/tests/Polyphony.Tests/Commands/RouteCommandTests.cs b/tests/Polyphony.Tests/Commands/RouteCommandTests.cs new file mode 100644 index 00000000..6e80fe67 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/RouteCommandTests.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Routing; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// End-to-end tests for using an in-memory SQLite database. +/// Tests verify the command output shape, exit codes, phase detection, workspace hints, +/// and error handling for missing work items. +/// +public sealed class RouteCommandTests : CommandTestBase +{ + private RouteCommand CreateCommand() => new(new PhaseDetector(Config), Repository, Config); + + [Fact] + public async Task Route_WorkItemNotFound_ReturnsCacheErrorExitCode() + { + var cmd = CreateCommand(); + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.Route(999)); + + exitCode.ShouldBe(ExitCodes.CacheError); + } + + [Fact] + public async Task Route_WorkItemNotFound_OutputsErrorJson() + { + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Route(999)); + + output.ShouldContain("\"error\""); + output.ShouldContain("\"work_item_id\":999"); + } + + [Fact] + public async Task Route_EpicInProposed_ReturnsNeedsPlanning() + { + var epic = new WorkItemBuilder() + .WithId(100) + .WithType("Epic") + .WithTitle("Test Epic") + .WithState("To Do") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(100)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.WorkItemId.ShouldBe(100); + result.Phase.ShouldBe(SdlcPhase.NeedsPlanning); + result.Action.ShouldBe(SdlcAction.Plan); + } + + [Fact] + public async Task Route_EpicInProgressNoChildren_ReturnsNeedsSeeding() + { + var epic = new WorkItemBuilder() + .WithId(101) + .WithType("Epic") + .WithTitle("Epic In Progress") + .WithState("Doing") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(101)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.NeedsSeeding); + result.Action.ShouldBe(SdlcAction.Seed); + } + + [Fact] + public async Task Route_EpicWithAllProposedChildren_ReturnsReadyForImplementation() + { + var (epic, children) = new WorkItemBuilder() + .WithId(102) + .WithType("Epic") + .WithTitle("Epic With Tasks") + .WithState("Doing") + .WithChildren( + new WorkItemBuilder().WithId(201).WithType("Task").WithTitle("Task 1").WithState("To Do"), + new WorkItemBuilder().WithId(202).WithType("Task").WithTitle("Task 2").WithState("To Do")) + .BuildAll(); + await SeedAsync([epic, .. children]); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(102)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.ReadyForImplementation); + result.Action.ShouldBe(SdlcAction.Implement); + } + + [Fact] + public async Task Route_EpicWithMixedChildren_ReturnsInProgress() + { + var (epic, children) = new WorkItemBuilder() + .WithId(103) + .WithType("Epic") + .WithTitle("Mixed Epic") + .WithState("Doing") + .WithChildren( + new WorkItemBuilder().WithId(203).WithType("Task").WithTitle("Done Task").WithState("Done"), + new WorkItemBuilder().WithId(204).WithType("Task").WithTitle("In Prog Task").WithState("Doing")) + .BuildAll(); + await SeedAsync([epic, .. children]); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(103)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.InProgress); + result.Action.ShouldBe(SdlcAction.Monitor); + } + + [Fact] + public async Task Route_EpicWithAllCompletedChildren_ReturnsReadyForCompletion() + { + var (epic, children) = new WorkItemBuilder() + .WithId(104) + .WithType("Epic") + .WithTitle("Completed Epic") + .WithState("Doing") + .WithChildren( + new WorkItemBuilder().WithId(205).WithType("Task").WithTitle("Task 1").WithState("Done"), + new WorkItemBuilder().WithId(206).WithType("Task").WithTitle("Task 2").WithState("Done")) + .BuildAll(); + await SeedAsync([epic, .. children]); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(104)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.ReadyForCompletion); + result.Action.ShouldBe(SdlcAction.Close); + } + + [Fact] + public async Task Route_TaskInProposed_ReturnsReadyForImplementation() + { + var task = new WorkItemBuilder() + .WithId(200) + .WithType("Task") + .WithTitle("Test Task") + .WithState("To Do") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(200)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.ReadyForImplementation); + result.Action.ShouldBe(SdlcAction.Implement); + } + + [Fact] + public async Task Route_TaskInProgress_ReturnsInProgress() + { + var task = new WorkItemBuilder() + .WithId(210) + .WithType("Task") + .WithTitle("Doing Task") + .WithState("Doing") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(210)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.InProgress); + result.Action.ShouldBe(SdlcAction.Monitor); + } + + [Fact] + public async Task Route_CompletedItem_ReturnsDone() + { + var task = new WorkItemBuilder() + .WithId(220) + .WithType("Task") + .WithTitle("Done Task") + .WithState("Done") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(220)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.Done); + result.Action.ShouldBe(SdlcAction.None); + } + + [Fact] + public async Task Route_RemovedItem_ReturnsRemoved() + { + var task = new WorkItemBuilder() + .WithId(230) + .WithType("Task") + .WithTitle("Removed Task") + .WithState("Removed") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(230)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.Removed); + result.Action.ShouldBe(SdlcAction.None); + } + + [Fact] + public async Task Route_OutputContainsWorkspaceHint() + { + var epic = new WorkItemBuilder() + .WithId(300) + .WithType("Epic") + .WithTitle("Branch Test Epic") + .WithState("To Do") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Route(300)); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.WorkspaceHint.ShouldNotBeNull(); + result.WorkspaceHint!.FeatureBranch.ShouldNotBeNull(); + result.WorkspaceHint.FeatureBranch!.ShouldContain("300"); + } + + [Fact] + public async Task Route_OutputUsesSnakeCasePropertyNames() + { + var task = new WorkItemBuilder() + .WithId(400) + .WithType("Task") + .WithTitle("Snake Case") + .WithState("To Do") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Route(400)); + + output.ShouldContain("\"work_item_id\""); + output.ShouldContain("\"phase\""); + output.ShouldContain("\"action\""); + } + + [Fact] + public async Task Route_IssueInProposedNoChildren_ReturnsNeedsPlanning() + { + // Issue is plannable + implementable; in Proposed → needs_planning + var issue = new WorkItemBuilder() + .WithId(500) + .WithType("Issue") + .WithTitle("Test Issue") + .WithState("To Do") + .Build(); + await SeedAsync(issue); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(500)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.NeedsPlanning); + result.Action.ShouldBe(SdlcAction.Plan); + } + + [Fact] + public async Task Route_IssueInProgressNoChildren_ReturnsReadyForImplementation() + { + // Issue is plannable + implementable; in InProgress with no children → direct implementation + var issue = new WorkItemBuilder() + .WithId(501) + .WithType("Issue") + .WithTitle("Impl Issue") + .WithState("Doing") + .Build(); + await SeedAsync(issue); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Route(501)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); + result.ShouldNotBeNull(); + result.Phase.ShouldBe(SdlcPhase.ReadyForImplementation); + result.Action.ShouldBe(SdlcAction.Implement); + } + + [Fact] + public async Task Route_SeededDatabase_WorkItemCanBeQueried() + { + var epic = new WorkItemBuilder() + .WithId(600) + .WithType("Epic") + .WithTitle("Infra Test") + .WithState("To Do") + .Build(); + await SeedAsync(epic); + + var loaded = await Repository.GetByIdAsync(600); + loaded.ShouldNotBeNull(); + loaded.Id.ShouldBe(600); + loaded.Title.ShouldBe("Infra Test"); + loaded.State.ShouldBe("To Do"); + } + + [Fact] + public async Task Route_SeededEpicWithChildren_ChildrenCanBeQueried() + { + var (epic, children) = new WorkItemBuilder() + .WithId(700) + .WithType("Epic") + .WithTitle("Parent Epic") + .WithState("Doing") + .WithChildren( + new WorkItemBuilder().WithId(701).WithType("Task").WithTitle("Child 1").WithState("To Do"), + new WorkItemBuilder().WithId(702).WithType("Task").WithTitle("Child 2").WithState("Doing")) + .BuildAll(); + await SeedAsync([epic, .. children]); + + var loadedChildren = await Repository.GetChildrenAsync(700); + loadedChildren.Count.ShouldBe(2); + } +} diff --git a/tests/Polyphony.Tests/Commands/ValidateCommandTests.cs b/tests/Polyphony.Tests/Commands/ValidateCommandTests.cs new file mode 100644 index 00000000..def41999 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/ValidateCommandTests.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Routing; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// End-to-end tests for using an in-memory SQLite database. +/// Tests verify exit codes, JSON output shape, and event/work-item fields. +/// The database is seeded with scenarios matching the coverage matrix. +/// +public sealed class ValidateCommandTests : CommandTestBase +{ + private ValidateCommand CreateCommand() => new(new TransitionValidator(Config), Repository); + + [Fact] + public async Task Validate_WorkItemNotFound_ReturnsCacheErrorExitCode() + { + var cmd = CreateCommand(); + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.Validate(999, "begin_planning")); + + exitCode.ShouldBe(ExitCodes.CacheError); + } + + [Fact] + public async Task Validate_WorkItemNotFound_OutputsErrorJson() + { + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Validate(999, "begin_planning")); + + output.ShouldContain("\"error\""); + output.ShouldContain("\"work_item_id\":999"); + } + + [Fact] + public async Task Validate_ValidEvent_ReturnsSuccessExitCode() + { + var epic = new WorkItemBuilder() + .WithId(100) + .WithType("Epic") + .WithTitle("Test Epic") + .WithState("To Do") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.Validate(100, "begin_planning")); + + exitCode.ShouldBe(ExitCodes.Success); + } + + [Fact] + public async Task Validate_ValidEvent_ReturnsIsValidTrueWithTargetState() + { + var epic = new WorkItemBuilder() + .WithId(100) + .WithType("Epic") + .WithTitle("Test Epic") + .WithState("To Do") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Validate(100, "begin_planning")); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ValidateResult); + result.ShouldNotBeNull(); + result.WorkItemId.ShouldBe(100); + result.Event.ShouldBe("begin_planning"); + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("Doing"); + } + + [Fact] + public async Task Validate_UnknownEvent_ReturnsRoutingFailureExitCode() + { + var task = new WorkItemBuilder() + .WithId(200) + .WithType("Task") + .WithTitle("Test Task") + .WithState("To Do") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.Validate(200, "nonexistent_event")); + + exitCode.ShouldBe(ExitCodes.RoutingFailure); + } + + [Fact] + public async Task Validate_UnknownEvent_ReturnsIsValidFalseWithMessage() + { + var task = new WorkItemBuilder() + .WithId(200) + .WithType("Task") + .WithTitle("Test Task") + .WithState("To Do") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Validate(200, "nonexistent_event")); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ValidateResult); + result.ShouldNotBeNull(); + result.IsValid.ShouldBeFalse(); + result.Message.ShouldNotBeNullOrEmpty(); + result.Message.ShouldContain("nonexistent_event"); + } + + [Fact] + public async Task Validate_InvalidPrecondition_ReturnsRoutingFailureExitCode() + { + // Epic in "Doing" (InProgress) — begin_planning requires Proposed + var epic = new WorkItemBuilder() + .WithId(101) + .WithType("Epic") + .WithTitle("In Progress Epic") + .WithState("Doing") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.Validate(101, "begin_planning")); + + exitCode.ShouldBe(ExitCodes.RoutingFailure); + } + + [Fact] + public async Task Validate_InvalidPrecondition_ReturnsIsValidFalseWithMessage() + { + var epic = new WorkItemBuilder() + .WithId(101) + .WithType("Epic") + .WithTitle("In Progress Epic") + .WithState("Doing") + .Build(); + await SeedAsync(epic); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Validate(101, "begin_planning")); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ValidateResult); + result.ShouldNotBeNull(); + result.IsValid.ShouldBeFalse(); + result.TargetState.ShouldBe("Doing"); + result.Message!.ShouldContain("begin_planning"); + } + + [Fact] + public async Task Validate_OutputContainsIsValidField() + { + var task = new WorkItemBuilder() + .WithId(300) + .WithType("Task") + .WithTitle("Snake Case Check") + .WithState("To Do") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Validate(300, "begin_implementation")); + + output.ShouldContain("\"is_valid\""); + } + + [Fact] + public async Task Validate_OutputUsesSnakeCasePropertyNames() + { + var task = new WorkItemBuilder() + .WithId(400) + .WithType("Task") + .WithTitle("Output Format") + .WithState("To Do") + .Build(); + await SeedAsync(task); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Validate(400, "begin_implementation")); + + output.ShouldContain("\"work_item_id\""); + output.ShouldContain("\"is_valid\""); + output.ShouldContain("\"target_state\""); + } + + [Fact] + public async Task Validate_SeededDatabase_WorkItemCanBeQueried() + { + var issue = new WorkItemBuilder() + .WithId(500) + .WithType("Issue") + .WithTitle("Validation Target") + .WithState("Doing") + .Build(); + await SeedAsync(issue); + + var loaded = await Repository.GetByIdAsync(500); + loaded.ShouldNotBeNull(); + loaded.Id.ShouldBe(500); + loaded.State.ShouldBe("Doing"); + } + + [Fact] + public async Task Validate_AllChildrenComplete_EventIsValid() + { + var (epic, children) = new WorkItemBuilder() + .WithId(600) + .WithType("Epic") + .WithTitle("Completed Children Epic") + .WithState("Doing") + .WithChildren( + new WorkItemBuilder().WithId(601).WithType("Task").WithTitle("Task 1").WithState("Done"), + new WorkItemBuilder().WithId(602).WithType("Task").WithTitle("Task 2").WithState("Done")) + .BuildAll(); + await SeedAsync([epic, .. children]); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Validate(600, "implementation_complete")); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ValidateResult); + result.ShouldNotBeNull(); + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("Done"); + } +} diff --git a/tests/Polyphony.Tests/ExitCodesTests.cs b/tests/Polyphony.Tests/ExitCodesTests.cs new file mode 100644 index 00000000..90438087 --- /dev/null +++ b/tests/Polyphony.Tests/ExitCodesTests.cs @@ -0,0 +1,68 @@ +using Shouldly; +using Xunit; + +namespace Polyphony.Tests; + +/// +/// Tests for ensuring constant values match the documented +/// exit code scheme used by conductor shell scripts for workflow branching. +/// +public sealed class ExitCodesTests +{ + [Fact] + public void Success_IsZero() + { + ExitCodes.Success.ShouldBe(0); + } + + [Fact] + public void RoutingFailure_IsOne() + { + ExitCodes.RoutingFailure.ShouldBe(1); + } + + [Fact] + public void ConfigError_IsTwo() + { + ExitCodes.ConfigError.ShouldBe(2); + } + + [Fact] + public void CacheError_IsThree() + { + ExitCodes.CacheError.ShouldBe(3); + } + + [Fact] + public void AllCodes_AreDistinct() + { + var codes = new[] + { + ExitCodes.Success, + ExitCodes.RoutingFailure, + ExitCodes.ConfigError, + ExitCodes.CacheError + }; + + codes.ShouldBeUnique(); + } + + [Theory] + [InlineData(nameof(ExitCodes.Success), 0)] + [InlineData(nameof(ExitCodes.RoutingFailure), 1)] + [InlineData(nameof(ExitCodes.ConfigError), 2)] + [InlineData(nameof(ExitCodes.CacheError), 3)] + public void ExitCode_MatchesDocumentedScheme(string name, int expected) + { + var actual = name switch + { + nameof(ExitCodes.Success) => ExitCodes.Success, + nameof(ExitCodes.RoutingFailure) => ExitCodes.RoutingFailure, + nameof(ExitCodes.ConfigError) => ExitCodes.ConfigError, + nameof(ExitCodes.CacheError) => ExitCodes.CacheError, + _ => throw new ArgumentOutOfRangeException(nameof(name)) + }; + + actual.ShouldBe(expected, $"ExitCodes.{name} should be {expected}"); + } +} diff --git a/tests/Polyphony.Tests/Infrastructure/PolyphonyServiceRegistrationTests.cs b/tests/Polyphony.Tests/Infrastructure/PolyphonyServiceRegistrationTests.cs new file mode 100644 index 00000000..1a796331 --- /dev/null +++ b/tests/Polyphony.Tests/Infrastructure/PolyphonyServiceRegistrationTests.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.DependencyInjection; +using Polyphony.Configuration; +using Polyphony.Infrastructure; +using Polyphony.Tests.Configuration; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Infrastructure; + +public sealed class PolyphonyServiceRegistrationTests +{ + [Fact] + public void AddPolyphonyServices_RegistersProcessConfig() + { + // Arrange + var configPath = FindProcessConfigPath(); + if (configPath is null) return; + + var services = new ServiceCollection(); + + // Act + services.AddPolyphonyServices(configPath, twigDir: null); + + // Assert — ProcessConfig is registered + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ProcessConfig)); + descriptor.ShouldNotBeNull(); + descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + } + + [Fact] + public void AddPolyphonyServices_RegistersTwigCoreServices() + { + // Arrange + var configPath = FindProcessConfigPath(); + if (configPath is null) return; + + var services = new ServiceCollection(); + + // Act + services.AddPolyphonyServices(configPath, twigDir: null); + + // Assert — twig core services are registered (check by type name to avoid + // needing a direct project reference to Twig.Infrastructure in test project) + var typeNames = services.Select(d => d.ServiceType.Name).ToList(); + typeNames.ShouldContain("IWorkItemRepository"); + typeNames.ShouldContain("IProcessTypeStore"); + typeNames.ShouldContain("IContextStore"); + typeNames.ShouldContain("SqliteCacheStore"); + typeNames.ShouldContain("TwigPaths"); + } + + [Fact] + public void AddPolyphonyServices_ResolvesProcessConfig() + { + // Arrange + var configPath = FindProcessConfigPath(); + if (configPath is null) return; + + var services = new ServiceCollection(); + services.AddPolyphonyServices(configPath, twigDir: null); + using var provider = services.BuildServiceProvider(); + + // Act + var config = provider.GetRequiredService(); + + // Assert + config.ShouldNotBeNull(); + config.ProcessTemplate.ShouldNotBeNullOrWhiteSpace(); + config.Types.ShouldNotBeEmpty(); + } + + [Fact] + public void AddPolyphonyServices_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var result = services.AddPolyphonyServices("nonexistent.yaml", twigDir: null); + + // Assert — fluent API returns the same collection + result.ShouldBeSameAs(services); + } + + [Fact] + public void AddPolyphonyServices_ProcessConfigResolutionFailsForMissingFile() + { + // Arrange + var services = new ServiceCollection(); + services.AddPolyphonyServices("nonexistent-config.yaml", twigDir: null); + using var provider = services.BuildServiceProvider(); + + // Act & Assert — factory throws FileNotFoundException on resolution + Should.Throw(() => provider.GetRequiredService()); + } + + [Fact] + public void AddPolyphonyServices_AcceptsExplicitTwigDir() + { + // Arrange + var services = new ServiceCollection(); + var customTwigDir = Path.Combine(Path.GetTempPath(), "test-twig-dir"); + + // Act — should not throw during registration + services.AddPolyphonyServices("any-config.yaml", twigDir: customTwigDir); + + // Assert — TwigPaths is registered (resolution deferred) + var typeNames = services.Select(d => d.ServiceType.Name).ToList(); + typeNames.ShouldContain("TwigPaths"); + } + + private static string? FindProcessConfigPath() + { + var twig2Root = TestHelpers.FindRepoRoot("twig2"); + var path = Path.Combine(twig2Root, ".conductor", "process-config.yaml"); + return File.Exists(path) ? path : null; + } +} diff --git a/tests/Polyphony.Tests/Infrastructure/TwigCacheLocatorTests.cs b/tests/Polyphony.Tests/Infrastructure/TwigCacheLocatorTests.cs new file mode 100644 index 00000000..0de7a8c3 --- /dev/null +++ b/tests/Polyphony.Tests/Infrastructure/TwigCacheLocatorTests.cs @@ -0,0 +1,173 @@ +using Polyphony.Infrastructure; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Infrastructure; + +/// +/// Tests for covering explicit path resolution, +/// walk-up discovery, and error cases. +/// Uses real temporary directories to exercise actual filesystem traversal. +/// +public sealed class TwigCacheLocatorTests : IDisposable +{ + private readonly string _tempRoot; + + public TwigCacheLocatorTests() + { + _tempRoot = Path.Combine(Path.GetTempPath(), $"twig-locator-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempRoot); + } + + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + Directory.Delete(_tempRoot, recursive: true); + } + + // ──────────── Explicit path: IS .twig/ directory ──────────── + + [Fact] + public void ResolveTwigDir_ExplicitPathIsTwigDir_ReturnsPath() + { + var twigDir = Path.Combine(_tempRoot, ".twig"); + Directory.CreateDirectory(twigDir); + + var result = TwigCacheLocator.ResolveTwigDir(twigDir); + + result.ShouldBe(twigDir); + } + + // ──────────── Explicit path: CONTAINS .twig/ subdirectory ──────────── + + [Fact] + public void ResolveTwigDir_ExplicitPathContainsTwigSubDir_ReturnsTwigSubDir() + { + var twigDir = Path.Combine(_tempRoot, ".twig"); + Directory.CreateDirectory(twigDir); + + var result = TwigCacheLocator.ResolveTwigDir(_tempRoot); + + result.ShouldBe(twigDir); + } + + // ──────────── Explicit path: does not exist ──────────── + + [Fact] + public void ResolveTwigDir_ExplicitPathDoesNotExist_ThrowsDirectoryNotFound() + { + var badPath = Path.Combine(_tempRoot, "nonexistent"); + + var ex = Should.Throw( + () => TwigCacheLocator.ResolveTwigDir(badPath)); + + ex.Message.ShouldContain("does not exist"); + } + + // ──────────── Explicit path: exists but has no .twig/ ──────────── + + [Fact] + public void ResolveTwigDir_ExplicitPathExistsButNoTwig_ThrowsDirectoryNotFound() + { + var emptyDir = Path.Combine(_tempRoot, "project"); + Directory.CreateDirectory(emptyDir); + + var ex = Should.Throw( + () => TwigCacheLocator.ResolveTwigDir(emptyDir)); + + ex.Message.ShouldContain(".twig/"); + } + + // ──────────── Walk-up: finds .twig/ in start directory ──────────── + + [Fact] + public void ResolveTwigDir_NullPath_FindsTwigInStartDir() + { + var twigDir = Path.Combine(_tempRoot, ".twig"); + Directory.CreateDirectory(twigDir); + + var result = TwigCacheLocator.ResolveTwigDir(null, startDir: _tempRoot); + + result.ShouldBe(twigDir); + } + + // ──────────── Walk-up: finds .twig/ in ancestor directory ──────────── + + [Fact] + public void ResolveTwigDir_NullPath_FindsTwigInAncestor() + { + var twigDir = Path.Combine(_tempRoot, ".twig"); + Directory.CreateDirectory(twigDir); + + var child = Path.Combine(_tempRoot, "src", "MyProject"); + Directory.CreateDirectory(child); + + var result = TwigCacheLocator.ResolveTwigDir(null, startDir: child); + + result.ShouldBe(twigDir); + } + + // ──────────── Walk-up: deeply nested ──────────── + + [Fact] + public void ResolveTwigDir_DeeplyNested_WalksUpMultipleLevels() + { + var twigDir = Path.Combine(_tempRoot, ".twig"); + Directory.CreateDirectory(twigDir); + + var deep = Path.Combine(_tempRoot, "a", "b", "c", "d", "e"); + Directory.CreateDirectory(deep); + + var result = TwigCacheLocator.ResolveTwigDir(null, startDir: deep); + + result.ShouldBe(twigDir); + } + + // ──────────── Walk-up: .twig/ not found anywhere ──────────── + + [Fact] + public void ResolveTwigDir_NoTwigAnywhere_ThrowsWithHelpfulMessage() + { + // Use a path near the drive root to avoid finding .twig/ from the + // test runner's own repo during walk-up. + var testRoot = OperatingSystem.IsWindows() + ? Path.Combine(Path.GetPathRoot(Path.GetTempPath())!, $"twig-test-nf-{Guid.NewGuid():N}") + : Path.Combine(Path.GetTempPath(), $"twig-test-nf-{Guid.NewGuid():N}"); + var deep = Path.Combine(testRoot, "a", "b"); + Directory.CreateDirectory(deep); + + try + { + var ex = Should.Throw( + () => TwigCacheLocator.ResolveTwigDir(null, startDir: deep)); + + ex.Message.ShouldContain("--twig-dir"); + } + finally + { + Directory.Delete(testRoot, recursive: true); + } + } + + // ──────────── Walk-up: nearest .twig/ wins ──────────── + + [Fact] + public void ResolveTwigDir_MultipleTwigDirs_ReturnsNearest() + { + // Outer .twig/ + var outerTwig = Path.Combine(_tempRoot, ".twig"); + Directory.CreateDirectory(outerTwig); + + // Inner .twig/ (closer to start) + var innerDir = Path.Combine(_tempRoot, "nested"); + var innerTwig = Path.Combine(innerDir, ".twig"); + Directory.CreateDirectory(innerTwig); + + var child = Path.Combine(innerDir, "src"); + Directory.CreateDirectory(child); + + var result = TwigCacheLocator.ResolveTwigDir(null, startDir: child); + + result.ShouldBe(innerTwig); + } +} diff --git a/tests/Polyphony.Tests/Polyphony.Tests.csproj b/tests/Polyphony.Tests/Polyphony.Tests.csproj index 77cef196..9eb689f3 100644 --- a/tests/Polyphony.Tests/Polyphony.Tests.csproj +++ b/tests/Polyphony.Tests/Polyphony.Tests.csproj @@ -14,5 +14,7 @@ + + diff --git a/tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs b/tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs new file mode 100644 index 00000000..2b78ff94 --- /dev/null +++ b/tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs @@ -0,0 +1,123 @@ +using Polyphony.Routing; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Routing; + +/// +/// Unit tests for verifying template substitution and slug generation. +/// +public sealed class BranchNameResolverTests +{ + [Fact] + public void Resolve_WithBranchStrategy_SubstitutesIdInTemplate() + { + var config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .WithBranchStrategy(featureBranch: "feature/{id}") + .Build(); + var item = new WorkItemBuilder().WithId(42).WithType("Epic").WithTitle("Test").WithState("To Do").Build(); + + var hint = BranchNameResolver.Resolve(config, item); + + hint.ShouldNotBeNull(); + hint.FeatureBranch.ShouldBe("feature/42"); + } + + [Fact] + public void Resolve_WithRootIdPlaceholder_SubstitutesCorrectly() + { + var config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .WithBranchStrategy(featureBranch: "feature/{root_id}-test") + .Build(); + var item = new WorkItemBuilder().WithId(100).WithType("Epic").WithTitle("Test").WithState("To Do").Build(); + + var hint = BranchNameResolver.Resolve(config, item); + + hint.ShouldNotBeNull(); + hint.FeatureBranch.ShouldBe("feature/100-test"); + } + + [Fact] + public void Resolve_WithSlugPlaceholder_GeneratesSlugFromTitle() + { + var config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .WithBranchStrategy(featureBranch: "feature/{id}-{slug}") + .Build(); + var item = new WorkItemBuilder().WithId(10).WithType("Epic").WithTitle("My Cool Feature").WithState("To Do").Build(); + + var hint = BranchNameResolver.Resolve(config, item); + + hint.ShouldNotBeNull(); + hint.FeatureBranch.ShouldBe("feature/10-my-cool-feature"); + } + + [Fact] + public void Resolve_PgBranch_SubstitutesPlaceholders() + { + var config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .WithBranchStrategy(pgBranch: "feature/{id}-pg-{slug}") + .Build(); + var item = new WorkItemBuilder().WithId(50).WithType("Epic").WithTitle("Test").WithState("To Do").Build(); + + var hint = BranchNameResolver.Resolve(config, item); + + hint.ShouldNotBeNull(); + hint.PgBranch.ShouldBe("feature/50-pg-test"); + } + + [Fact] + public void Resolve_NoBranchStrategy_ReturnsNull() + { + var config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .Build(); + var item = new WorkItemBuilder().WithId(1).WithType("Epic").WithTitle("Test").WithState("To Do").Build(); + + var hint = BranchNameResolver.Resolve(config, item); + + hint.ShouldBeNull(); + } + + [Fact] + public void Slugify_SpecialCharacters_ReplacedWithHyphens() + { + var slug = BranchNameResolver.Slugify("Hello, World! This is a test."); + slug.ShouldBe("hello-world-this-is-a-test"); + } + + [Fact] + public void Slugify_ConsecutiveSpecialChars_CollapsedToSingleHyphen() + { + var slug = BranchNameResolver.Slugify("Hello---World"); + slug.ShouldBe("hello-world"); + } + + [Fact] + public void Slugify_EmptyString_ReturnsEmpty() + { + var slug = BranchNameResolver.Slugify(""); + slug.ShouldBe(""); + } + + [Fact] + public void Slugify_LongTitle_TruncatesTo50Chars() + { + var longTitle = new string('a', 100); + var slug = BranchNameResolver.Slugify(longTitle); + slug.Length.ShouldBeLessThanOrEqualTo(50); + } + + [Fact] + public void Slugify_TrailingHyphenAfterTruncation_Removed() + { + // Create a title that would have a hyphen at position 50 after slugify + var title = new string('a', 49) + " b"; + var slug = BranchNameResolver.Slugify(title); + slug.ShouldNotEndWith("-"); + } +} diff --git a/tests/Polyphony.Tests/Routing/HierarchyWalkerTests.cs b/tests/Polyphony.Tests/Routing/HierarchyWalkerTests.cs new file mode 100644 index 00000000..dbb1d0da --- /dev/null +++ b/tests/Polyphony.Tests/Routing/HierarchyWalkerTests.cs @@ -0,0 +1,266 @@ +using NSubstitute; +using Polyphony.Configuration; +using Polyphony.Routing; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Twig.Domain.Aggregates; +using Twig.Domain.Interfaces; +using Xunit; + +namespace Polyphony.Tests.Routing; + +/// +/// Unit tests for using NSubstitute mocks. +/// Covers depth limiting, capability annotation, missing items, and multi-level trees. +/// +public sealed class HierarchyWalkerTests +{ + private readonly IWorkItemRepository _repository = Substitute.For(); + + private readonly ProcessConfig _config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .WithType("Issue", ["plannable", "implementable"]) + .WithType("Task", ["implementable"]) + .Build(); + + private HierarchyWalker CreateWalker() => new(_config, _repository); + + [Fact] + public async Task WalkAsync_MissingRoot_ReturnsNull() + { + _repository.GetByIdAsync(999, Arg.Any()) + .Returns((WorkItem?)null); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(999, maxDepth: 3, CancellationToken.None); + + result.ShouldBeNull(); + } + + [Fact] + public async Task WalkAsync_DepthZero_ReturnsRootOnly() + { + var root = new WorkItemBuilder() + .WithId(1) + .WithType("Epic") + .WithTitle("Root Epic") + .WithState("Doing") + .Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(root); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 0, CancellationToken.None); + + result.ShouldNotBeNull(); + result.WorkItemId.ShouldBe(1); + result.Title.ShouldBe("Root Epic"); + result.Type.ShouldBe("Epic"); + result.State.ShouldBe("Doing"); + result.Children.ShouldBeNull(); + } + + [Fact] + public async Task WalkAsync_DepthZero_DoesNotLoadChildren() + { + var root = new WorkItemBuilder() + .WithId(1) + .WithType("Epic") + .WithTitle("Root Epic") + .WithState("Doing") + .Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(root); + + var walker = CreateWalker(); + await walker.WalkAsync(1, maxDepth: 0, CancellationToken.None); + + await _repository.DidNotReceive().GetChildrenAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task WalkAsync_DepthOne_IncludesDirectChildren() + { + var root = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithTitle("Root").WithState("Doing").Build(); + var child1 = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithTitle("Child Issue").WithState("To Do").Build(); + var child2 = new WorkItemBuilder() + .WithId(11).WithType("Task").WithTitle("Child Task").WithState("Doing").Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(root); + _repository.GetChildrenAsync(1, Arg.Any()) + .Returns(new List { child1, child2 }); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 1, CancellationToken.None); + + result.ShouldNotBeNull(); + result.Children.ShouldNotBeNull(); + result.Children.Length.ShouldBe(2); + result.Children[0].WorkItemId.ShouldBe(10); + result.Children[0].Type.ShouldBe("Issue"); + result.Children[1].WorkItemId.ShouldBe(11); + result.Children[1].Type.ShouldBe("Task"); + } + + [Fact] + public async Task WalkAsync_DepthOne_ChildrenHaveNoChildren() + { + var root = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithTitle("Root").WithState("Doing").Build(); + var child = new WorkItemBuilder() + .WithId(10).WithType("Task").WithTitle("Child").WithState("To Do").Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(root); + _repository.GetChildrenAsync(1, Arg.Any()) + .Returns(new List { child }); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 1, CancellationToken.None); + + result.ShouldNotBeNull(); + result.Children.ShouldNotBeNull(); + result.Children[0].Children.ShouldBeNull(); + + // Should not try to load grandchildren at depth 1 + await _repository.DidNotReceive().GetChildrenAsync(10, Arg.Any()); + } + + [Fact] + public async Task WalkAsync_ThreeLevelTree_ReturnsFullHierarchy() + { + // Epic (1) → Issue (10) → Task (100) + var epic = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithTitle("Epic").WithState("Doing").Build(); + var issue = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithTitle("Issue").WithState("Doing").Build(); + var task = new WorkItemBuilder() + .WithId(100).WithType("Task").WithTitle("Task").WithState("To Do").Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(epic); + _repository.GetChildrenAsync(1, Arg.Any()) + .Returns(new List { issue }); + _repository.GetChildrenAsync(10, Arg.Any()) + .Returns(new List { task }); + _repository.GetChildrenAsync(100, Arg.Any()) + .Returns(new List()); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 3, CancellationToken.None); + + result.ShouldNotBeNull(); + result.WorkItemId.ShouldBe(1); + + result.Children.ShouldNotBeNull(); + result.Children.Length.ShouldBe(1); + result.Children[0].WorkItemId.ShouldBe(10); + + result.Children[0].Children.ShouldNotBeNull(); + result.Children[0].Children!.Length.ShouldBe(1); + result.Children[0].Children![0].WorkItemId.ShouldBe(100); + } + + [Fact] + public async Task WalkAsync_AnnotatesCapabilitiesFromProcessConfig() + { + var epic = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithTitle("Epic").WithState("Doing").Build(); + var issue = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithTitle("Issue").WithState("To Do").Build(); + var task = new WorkItemBuilder() + .WithId(100).WithType("Task").WithTitle("Task").WithState("To Do").Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(epic); + _repository.GetChildrenAsync(1, Arg.Any()) + .Returns(new List { issue, task }); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 1, CancellationToken.None); + + result.ShouldNotBeNull(); + result.Capabilities.ShouldBe(["plannable"]); + + result.Children.ShouldNotBeNull(); + result.Children[0].Capabilities.ShouldBe(["plannable", "implementable"]); + result.Children[1].Capabilities.ShouldBe(["implementable"]); + } + + [Fact] + public async Task WalkAsync_UnknownType_ReturnsEmptyCapabilities() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Bug").WithTitle("A Bug").WithState("New").Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(item); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 0, CancellationToken.None); + + result.ShouldNotBeNull(); + result.Type.ShouldBe("Bug"); + result.Capabilities.ShouldBeEmpty(); + } + + [Fact] + public async Task WalkAsync_LeafNodeWithNoChildren_ChildrenIsNull() + { + var leaf = new WorkItemBuilder() + .WithId(1).WithType("Task").WithTitle("Leaf").WithState("To Do").Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(leaf); + _repository.GetChildrenAsync(1, Arg.Any()) + .Returns(new List()); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 3, CancellationToken.None); + + result.ShouldNotBeNull(); + result.Children.ShouldBeNull(); + } + + [Fact] + public async Task WalkAsync_DepthTruncatesAtMaxDepth() + { + // 3-level tree: Epic → Issue → Task, but maxDepth=1 should cut at Issue + var epic = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithTitle("Epic").WithState("Doing").Build(); + var issue = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithTitle("Issue").WithState("Doing").Build(); + + _repository.GetByIdAsync(1, Arg.Any()).Returns(epic); + _repository.GetChildrenAsync(1, Arg.Any()) + .Returns(new List { issue }); + + var walker = CreateWalker(); + var result = await walker.WalkAsync(1, maxDepth: 1, CancellationToken.None); + + result.ShouldNotBeNull(); + result.Children.ShouldNotBeNull(); + result.Children[0].Children.ShouldBeNull(); + + // Should not try to load grandchildren + await _repository.DidNotReceive().GetChildrenAsync(10, Arg.Any()); + } + + [Fact] + public async Task WalkAsync_CancellationRespected() + { + var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var root = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithTitle("Root").WithState("Doing").Build(); + + _repository.GetByIdAsync(1, Arg.Any()) + .Returns(callInfo => + { + callInfo.Arg().ThrowIfCancellationRequested(); + return root; + }); + + var walker = CreateWalker(); + await Should.ThrowAsync( + () => walker.WalkAsync(1, maxDepth: 3, cts.Token)); + } +} diff --git a/tests/Polyphony.Tests/Routing/PhaseDetectorTests.cs b/tests/Polyphony.Tests/Routing/PhaseDetectorTests.cs new file mode 100644 index 00000000..d9a17177 --- /dev/null +++ b/tests/Polyphony.Tests/Routing/PhaseDetectorTests.cs @@ -0,0 +1,256 @@ +using Polyphony.Routing; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Routing; + +/// +/// Unit tests for covering all paths in the phase detection rules table. +/// +public sealed class PhaseDetectorTests +{ + private static PhaseDetector CreateDetector(ProcessConfigBuilder? configBuilder = null) + { + var config = (configBuilder ?? DefaultConfigBuilder()).Build(); + return new PhaseDetector(config); + } + + private static ProcessConfigBuilder DefaultConfigBuilder() + { + return new ProcessConfigBuilder() + .WithType("Epic", ["plannable"], new Dictionary + { + ["begin_planning"] = "Doing", + ["implementation_complete"] = "Done" + }) + .WithType("Issue", ["plannable", "implementable"], new Dictionary + { + ["begin_planning"] = "Doing", + ["implementation_complete"] = "Done" + }) + .WithType("Task", ["implementable"], new Dictionary + { + ["begin_implementation"] = "Doing", + ["implementation_complete"] = "Done" + }); + } + + // --- Plannable (Epic) --- + + [Fact] + public void Detect_EpicInProposed_ReturnsNeedsPlanning() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Epic").WithState("To Do").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.NeedsPlanning); + result.Action.ShouldBe(SdlcAction.Plan); + } + + [Fact] + public void Detect_EpicInProgressNoChildren_ReturnsNeedsSeeding() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Epic").WithState("Doing").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.NeedsSeeding); + result.Action.ShouldBe(SdlcAction.Seed); + } + + [Fact] + public void Detect_EpicInProgressAllProposedChildren_ReturnsReadyForImplementation() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithId(1).WithType("Epic").WithState("Doing").Build(); + var child1 = new WorkItemBuilder().WithId(2).WithType("Task").WithState("To Do").Build(); + var child2 = new WorkItemBuilder().WithId(3).WithType("Task").WithState("To Do").Build(); + + var result = detector.Detect(item, [child1, child2]); + + result.Phase.ShouldBe(SdlcPhase.ReadyForImplementation); + result.Action.ShouldBe(SdlcAction.Implement); + } + + [Fact] + public void Detect_EpicInProgressMixedChildren_ReturnsInProgress() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithId(1).WithType("Epic").WithState("Doing").Build(); + var child1 = new WorkItemBuilder().WithId(2).WithType("Task").WithState("Done").Build(); + var child2 = new WorkItemBuilder().WithId(3).WithType("Task").WithState("To Do").Build(); + + var result = detector.Detect(item, [child1, child2]); + + result.Phase.ShouldBe(SdlcPhase.InProgress); + result.Action.ShouldBe(SdlcAction.Monitor); + } + + [Fact] + public void Detect_EpicInProgressAllCompleted_ReturnsReadyForCompletion() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithId(1).WithType("Epic").WithState("Doing").Build(); + var child1 = new WorkItemBuilder().WithId(2).WithType("Task").WithState("Done").Build(); + var child2 = new WorkItemBuilder().WithId(3).WithType("Task").WithState("Done").Build(); + + var result = detector.Detect(item, [child1, child2]); + + result.Phase.ShouldBe(SdlcPhase.ReadyForCompletion); + result.Action.ShouldBe(SdlcAction.Close); + } + + [Fact] + public void Detect_EpicWithRemovedChildren_TreatsRemovedAsComplete() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithId(1).WithType("Epic").WithState("Doing").Build(); + var child1 = new WorkItemBuilder().WithId(2).WithType("Task").WithState("Done").Build(); + var child2 = new WorkItemBuilder().WithId(3).WithType("Task").WithState("Removed").Build(); + + var result = detector.Detect(item, [child1, child2]); + + result.Phase.ShouldBe(SdlcPhase.ReadyForCompletion); + result.Action.ShouldBe(SdlcAction.Close); + } + + // --- Implementable (Task) --- + + [Fact] + public void Detect_TaskInProposed_ReturnsReadyForImplementation() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Task").WithState("To Do").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.ReadyForImplementation); + result.Action.ShouldBe(SdlcAction.Implement); + } + + [Fact] + public void Detect_TaskInProgress_ReturnsInProgress() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Task").WithState("Doing").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.InProgress); + result.Action.ShouldBe(SdlcAction.Monitor); + } + + // --- Plannable + Implementable (Issue) --- + + [Fact] + public void Detect_IssueInProposed_ReturnsNeedsPlanning() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Issue").WithState("To Do").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.NeedsPlanning); + result.Action.ShouldBe(SdlcAction.Plan); + } + + [Fact] + public void Detect_IssueInProgressNoChildren_ReturnsReadyForImplementation() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Issue").WithState("Doing").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.ReadyForImplementation); + result.Action.ShouldBe(SdlcAction.Implement); + } + + [Fact] + public void Detect_IssueInProgressWithProposedChildren_ReturnsReadyForImplementation() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithId(1).WithType("Issue").WithState("Doing").Build(); + var child = new WorkItemBuilder().WithId(2).WithType("Task").WithState("To Do").Build(); + + var result = detector.Detect(item, [child]); + + result.Phase.ShouldBe(SdlcPhase.ReadyForImplementation); + result.Action.ShouldBe(SdlcAction.Implement); + } + + [Fact] + public void Detect_IssueInProgressWithAllDoneChildren_ReturnsReadyForCompletion() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithId(1).WithType("Issue").WithState("Doing").Build(); + var child = new WorkItemBuilder().WithId(2).WithType("Task").WithState("Done").Build(); + + var result = detector.Detect(item, [child]); + + result.Phase.ShouldBe(SdlcPhase.ReadyForCompletion); + result.Action.ShouldBe(SdlcAction.Close); + } + + // --- Terminal states --- + + [Fact] + public void Detect_CompletedItem_ReturnsDone() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Epic").WithState("Done").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.Done); + result.Action.ShouldBe(SdlcAction.None); + } + + [Fact] + public void Detect_RemovedItem_ReturnsRemoved() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Task").WithState("Removed").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.Removed); + result.Action.ShouldBe(SdlcAction.None); + } + + // --- Unknown type --- + + [Fact] + public void Detect_UnknownType_ReturnsUnknown() + { + var config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .Build(); + var detector = new PhaseDetector(config); + + // Use Task type but don't register it in config + var item = new WorkItemBuilder().WithType("Task").WithState("To Do").Build(); + + var result = detector.Detect(item, []); + + result.Phase.ShouldBe(SdlcPhase.Unknown); + result.Action.ShouldBe(SdlcAction.None); + } + + // --- Message populated --- + + [Fact] + public void Detect_AlwaysIncludesMessage() + { + var detector = CreateDetector(); + var item = new WorkItemBuilder().WithType("Epic").WithState("To Do").Build(); + + var result = detector.Detect(item, []); + + result.Message.ShouldNotBeNullOrWhiteSpace(); + } +} diff --git a/tests/Polyphony.Tests/Routing/RoutingDecisionTests.cs b/tests/Polyphony.Tests/Routing/RoutingDecisionTests.cs new file mode 100644 index 00000000..94e85d7e --- /dev/null +++ b/tests/Polyphony.Tests/Routing/RoutingDecisionTests.cs @@ -0,0 +1,119 @@ +using Polyphony.Routing; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Routing; + +/// +/// Tests for ensuring the record is properly constructible +/// and usable in unit tests with init-based properties. +/// +public sealed class RoutingDecisionTests +{ + [Fact] + public void Create_WithRequiredProperties_SetsValues() + { + var decision = new RoutingDecision + { + Phase = SdlcPhase.NeedsPlanning, + Action = SdlcAction.Plan + }; + + decision.Phase.ShouldBe(SdlcPhase.NeedsPlanning); + decision.Action.ShouldBe(SdlcAction.Plan); + decision.Message.ShouldBeNull(); + } + + [Fact] + public void Create_WithMessage_SetsAllProperties() + { + var decision = new RoutingDecision + { + Phase = SdlcPhase.InProgress, + Action = SdlcAction.Monitor, + Message = "3 of 5 children completed" + }; + + decision.Phase.ShouldBe(SdlcPhase.InProgress); + decision.Action.ShouldBe(SdlcAction.Monitor); + decision.Message.ShouldBe("3 of 5 children completed"); + } + + [Fact] + public void Equality_SameValues_AreEqual() + { + var a = new RoutingDecision + { + Phase = SdlcPhase.Done, + Action = SdlcAction.Close, + Message = "all done" + }; + + var b = new RoutingDecision + { + Phase = SdlcPhase.Done, + Action = SdlcAction.Close, + Message = "all done" + }; + + a.ShouldBe(b); + } + + [Fact] + public void Equality_DifferentPhase_AreNotEqual() + { + var a = new RoutingDecision { Phase = SdlcPhase.Done, Action = SdlcAction.Close }; + var b = new RoutingDecision { Phase = SdlcPhase.InProgress, Action = SdlcAction.Close }; + + a.ShouldNotBe(b); + } + + [Fact] + public void Equality_DifferentAction_AreNotEqual() + { + var a = new RoutingDecision { Phase = SdlcPhase.Done, Action = SdlcAction.Close }; + var b = new RoutingDecision { Phase = SdlcPhase.Done, Action = SdlcAction.None }; + + a.ShouldNotBe(b); + } + + [Fact] + public void Equality_DifferentMessage_AreNotEqual() + { + var a = new RoutingDecision { Phase = SdlcPhase.Done, Action = SdlcAction.Close, Message = "a" }; + var b = new RoutingDecision { Phase = SdlcPhase.Done, Action = SdlcAction.Close, Message = "b" }; + + a.ShouldNotBe(b); + } + + [Fact] + public void With_CreatesModifiedCopy() + { + var original = new RoutingDecision + { + Phase = SdlcPhase.NeedsPlanning, + Action = SdlcAction.Plan + }; + + var modified = original with { Phase = SdlcPhase.NeedsSeeding, Action = SdlcAction.Seed }; + + modified.Phase.ShouldBe(SdlcPhase.NeedsSeeding); + modified.Action.ShouldBe(SdlcAction.Seed); + original.Phase.ShouldBe(SdlcPhase.NeedsPlanning); + } + + [Fact] + public void ToString_ContainsPropertyValues() + { + var decision = new RoutingDecision + { + Phase = SdlcPhase.Unknown, + Action = SdlcAction.None, + Message = "test" + }; + + var str = decision.ToString(); + str.ShouldContain(SdlcPhase.Unknown); + str.ShouldContain(SdlcAction.None); + } +} diff --git a/tests/Polyphony.Tests/Routing/SdlcActionTests.cs b/tests/Polyphony.Tests/Routing/SdlcActionTests.cs new file mode 100644 index 00000000..cd09ce36 --- /dev/null +++ b/tests/Polyphony.Tests/Routing/SdlcActionTests.cs @@ -0,0 +1,71 @@ +using Polyphony.Routing; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Routing; + +/// +/// Tests for ensuring constant values match the documented +/// action scheme used by conductor scripts. +/// +public sealed class SdlcActionTests +{ + [Theory] + [InlineData(nameof(SdlcAction.Plan), "plan")] + [InlineData(nameof(SdlcAction.Seed), "seed")] + [InlineData(nameof(SdlcAction.Implement), "implement")] + [InlineData(nameof(SdlcAction.Monitor), "monitor")] + [InlineData(nameof(SdlcAction.Close), "close")] + [InlineData(nameof(SdlcAction.None), "none")] + public void Action_MatchesDocumentedValue(string name, string expected) + { + var actual = name switch + { + nameof(SdlcAction.Plan) => SdlcAction.Plan, + nameof(SdlcAction.Seed) => SdlcAction.Seed, + nameof(SdlcAction.Implement) => SdlcAction.Implement, + nameof(SdlcAction.Monitor) => SdlcAction.Monitor, + nameof(SdlcAction.Close) => SdlcAction.Close, + nameof(SdlcAction.None) => SdlcAction.None, + _ => throw new ArgumentOutOfRangeException(nameof(name)) + }; + + actual.ShouldBe(expected, $"SdlcAction.{name} should be \"{expected}\""); + } + + [Fact] + public void AllActions_AreDistinct() + { + var actions = new[] + { + SdlcAction.Plan, + SdlcAction.Seed, + SdlcAction.Implement, + SdlcAction.Monitor, + SdlcAction.Close, + SdlcAction.None + }; + + actions.ShouldBeUnique(); + } + + [Fact] + public void AllActions_UseLowercaseSnakeCase() + { + var actions = new[] + { + SdlcAction.Plan, + SdlcAction.Seed, + SdlcAction.Implement, + SdlcAction.Monitor, + SdlcAction.Close, + SdlcAction.None + }; + + foreach (var action in actions) + { + action.ShouldBe(action.ToLowerInvariant(), $"Action \"{action}\" should be lowercase"); + action.ShouldNotContain(" "); + } + } +} diff --git a/tests/Polyphony.Tests/Routing/SdlcPhaseTests.cs b/tests/Polyphony.Tests/Routing/SdlcPhaseTests.cs new file mode 100644 index 00000000..3048a239 --- /dev/null +++ b/tests/Polyphony.Tests/Routing/SdlcPhaseTests.cs @@ -0,0 +1,79 @@ +using Polyphony.Routing; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Routing; + +/// +/// Tests for ensuring constant values match the documented +/// phase detection rules table used by conductor scripts. +/// +public sealed class SdlcPhaseTests +{ + [Theory] + [InlineData(nameof(SdlcPhase.NeedsPlanning), "needs_planning")] + [InlineData(nameof(SdlcPhase.NeedsSeeding), "needs_seeding")] + [InlineData(nameof(SdlcPhase.ReadyForImplementation), "ready_for_implementation")] + [InlineData(nameof(SdlcPhase.InProgress), "in_progress")] + [InlineData(nameof(SdlcPhase.ReadyForCompletion), "ready_for_completion")] + [InlineData(nameof(SdlcPhase.Done), "done")] + [InlineData(nameof(SdlcPhase.Removed), "removed")] + [InlineData(nameof(SdlcPhase.Unknown), "unknown")] + public void Phase_MatchesDocumentedValue(string name, string expected) + { + var actual = name switch + { + nameof(SdlcPhase.NeedsPlanning) => SdlcPhase.NeedsPlanning, + nameof(SdlcPhase.NeedsSeeding) => SdlcPhase.NeedsSeeding, + nameof(SdlcPhase.ReadyForImplementation) => SdlcPhase.ReadyForImplementation, + nameof(SdlcPhase.InProgress) => SdlcPhase.InProgress, + nameof(SdlcPhase.ReadyForCompletion) => SdlcPhase.ReadyForCompletion, + nameof(SdlcPhase.Done) => SdlcPhase.Done, + nameof(SdlcPhase.Removed) => SdlcPhase.Removed, + nameof(SdlcPhase.Unknown) => SdlcPhase.Unknown, + _ => throw new ArgumentOutOfRangeException(nameof(name)) + }; + + actual.ShouldBe(expected, $"SdlcPhase.{name} should be \"{expected}\""); + } + + [Fact] + public void AllPhases_AreDistinct() + { + var phases = new[] + { + SdlcPhase.NeedsPlanning, + SdlcPhase.NeedsSeeding, + SdlcPhase.ReadyForImplementation, + SdlcPhase.InProgress, + SdlcPhase.ReadyForCompletion, + SdlcPhase.Done, + SdlcPhase.Removed, + SdlcPhase.Unknown + }; + + phases.ShouldBeUnique(); + } + + [Fact] + public void AllPhases_UseLowercaseSnakeCase() + { + var phases = new[] + { + SdlcPhase.NeedsPlanning, + SdlcPhase.NeedsSeeding, + SdlcPhase.ReadyForImplementation, + SdlcPhase.InProgress, + SdlcPhase.ReadyForCompletion, + SdlcPhase.Done, + SdlcPhase.Removed, + SdlcPhase.Unknown + }; + + foreach (var phase in phases) + { + phase.ShouldBe(phase.ToLowerInvariant(), $"Phase \"{phase}\" should be lowercase"); + phase.ShouldNotContain(" "); + } + } +} diff --git a/tests/Polyphony.Tests/Routing/TransitionValidatorTests.cs b/tests/Polyphony.Tests/Routing/TransitionValidatorTests.cs new file mode 100644 index 00000000..b845165b --- /dev/null +++ b/tests/Polyphony.Tests/Routing/TransitionValidatorTests.cs @@ -0,0 +1,294 @@ +using Polyphony.Configuration; +using Polyphony.Routing; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Twig.Domain.Aggregates; +using Xunit; + +namespace Polyphony.Tests.Routing; + +/// +/// Unit tests for . +/// Covers all 4 precondition types, happy paths, unknown events, and edge cases. +/// +public sealed class TransitionValidatorTests +{ + private readonly ProcessConfig _config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"], new Dictionary + { + ["begin_planning"] = "Doing", + ["all_children_complete"] = "Done", + }) + .WithType("Issue", ["plannable", "implementable"], new Dictionary + { + ["begin_planning"] = "Doing", + ["begin_implementation"] = "Doing", + ["implementation_complete"] = "Done", + ["all_children_complete"] = "Done", + }) + .WithType("Task", ["implementable"], new Dictionary + { + ["begin_implementation"] = "Doing", + ["implementation_complete"] = "Done", + }) + .Build(); + + private TransitionValidator CreateValidator() => new(_config); + + // ── Happy path tests ───────────────────────────────────────────── + + [Fact] + public void Validate_BeginPlanning_WhenProposed_ReturnsValid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("To Do").Build(); + + var result = CreateValidator().Validate(item, "begin_planning", []); + + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("Doing"); + result.WorkItemId.ShouldBe(1); + result.Event.ShouldBe("begin_planning"); + } + + [Fact] + public void Validate_BeginImplementation_WhenProposed_ReturnsValid() + { + var item = new WorkItemBuilder() + .WithId(10).WithType("Task").WithState("To Do").Build(); + + var result = CreateValidator().Validate(item, "begin_implementation", []); + + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("Doing"); + } + + [Fact] + public void Validate_BeginImplementation_WhenInProgress_ReturnsValid() + { + var item = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithState("Doing").Build(); + + var result = CreateValidator().Validate(item, "begin_implementation", []); + + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("Doing"); + } + + [Fact] + public void Validate_ImplementationComplete_WhenInProgress_ReturnsValid() + { + var item = new WorkItemBuilder() + .WithId(10).WithType("Task").WithState("Doing").Build(); + + var result = CreateValidator().Validate(item, "implementation_complete", []); + + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("Done"); + } + + [Fact] + public void Validate_AllChildrenComplete_WhenAllDone_ReturnsValid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("Doing").Build(); + var child1 = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithState("Done").Build(); + var child2 = new WorkItemBuilder() + .WithId(11).WithType("Issue").WithState("Done").Build(); + + var result = CreateValidator().Validate(item, "all_children_complete", [child1, child2]); + + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("Done"); + } + + // ── Unknown event tests ────────────────────────────────────────── + + [Fact] + public void Validate_UnknownEvent_ReturnsInvalid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("To Do").Build(); + + var result = CreateValidator().Validate(item, "nonexistent_event", []); + + result.IsValid.ShouldBeFalse(); + result.Message!.ShouldContain("Unknown event"); + result.Message!.ShouldContain("nonexistent_event"); + } + + [Fact] + public void Validate_UnknownType_ReturnsInvalid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Bug").WithState("New").Build(); + + var result = CreateValidator().Validate(item, "begin_planning", []); + + result.IsValid.ShouldBeFalse(); + result.Message!.ShouldContain("No transitions defined"); + result.Message!.ShouldContain("Bug"); + } + + // ── Precondition failure tests ─────────────────────────────────── + + [Fact] + public void Validate_BeginPlanning_WhenInProgress_ReturnsInvalid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("Doing").Build(); + + var result = CreateValidator().Validate(item, "begin_planning", []); + + result.IsValid.ShouldBeFalse(); + result.TargetState.ShouldBe("Doing"); + result.Message!.ShouldContain("begin_planning"); + result.Message!.ShouldContain("Proposed"); + } + + [Fact] + public void Validate_BeginImplementation_WhenCompleted_ReturnsInvalid() + { + var item = new WorkItemBuilder() + .WithId(10).WithType("Task").WithState("Done").Build(); + + var result = CreateValidator().Validate(item, "begin_implementation", []); + + result.IsValid.ShouldBeFalse(); + result.Message!.ShouldContain("begin_implementation"); + result.Message!.ShouldContain("Proposed or InProgress"); + } + + [Fact] + public void Validate_ImplementationComplete_WhenProposed_ReturnsInvalid() + { + var item = new WorkItemBuilder() + .WithId(10).WithType("Task").WithState("To Do").Build(); + + var result = CreateValidator().Validate(item, "implementation_complete", []); + + result.IsValid.ShouldBeFalse(); + result.Message!.ShouldContain("implementation_complete"); + result.Message!.ShouldContain("InProgress"); + } + + [Fact] + public void Validate_AllChildrenComplete_WhenChildNotDone_ReturnsInvalid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("Doing").Build(); + var child1 = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithState("Done").Build(); + var child2 = new WorkItemBuilder() + .WithId(11).WithType("Issue").WithState("Doing").Build(); + + var result = CreateValidator().Validate(item, "all_children_complete", [child1, child2]); + + result.IsValid.ShouldBeFalse(); + result.Message!.ShouldContain("all_children_complete"); + result.Message!.ShouldContain("child #11"); + } + + [Fact] + public void Validate_AllChildrenComplete_WhenNoChildren_ReturnsInvalid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("Doing").Build(); + + var result = CreateValidator().Validate(item, "all_children_complete", []); + + result.IsValid.ShouldBeFalse(); + result.Message!.ShouldContain("no children"); + } + + // ── Edge cases ─────────────────────────────────────────────────── + + [Fact] + public void Validate_EventWithNoPrecondition_ValidIfTransitionExists() + { + // Create a config with a custom event that has no precondition rule + var config = new ProcessConfigBuilder() + .WithType("Task", ["implementable"], new Dictionary + { + ["custom_event"] = "CustomState", + }) + .Build(); + + var validator = new TransitionValidator(config); + var item = new WorkItemBuilder() + .WithId(1).WithType("Task").WithState("To Do").Build(); + + var result = validator.Validate(item, "custom_event", []); + + result.IsValid.ShouldBeTrue(); + result.TargetState.ShouldBe("CustomState"); + } + + [Fact] + public void Validate_AllChildrenComplete_SingleChild_Done_ReturnsValid() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("Doing").Build(); + var child = new WorkItemBuilder() + .WithId(10).WithType("Task").WithState("Done").Build(); + + var result = CreateValidator().Validate(item, "all_children_complete", [child]); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void Validate_AllChildrenComplete_ChildClosed_ReturnsValid() + { + // "Closed" maps to Completed category via StateCategoryResolver + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("Doing").Build(); + var child = new WorkItemBuilder() + .WithId(10).WithType("Task").WithState("Closed").Build(); + + var result = CreateValidator().Validate(item, "all_children_complete", [child]); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void Validate_ValidTransition_IncludesTargetStateInMessage() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Task").WithState("Doing").Build(); + + var result = CreateValidator().Validate(item, "implementation_complete", []); + + result.IsValid.ShouldBeTrue(); + result.Message!.ShouldContain("Done"); + } + + [Fact] + public void Validate_FailedPrecondition_StillIncludesTargetState() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Task").WithState("To Do").Build(); + + var result = CreateValidator().Validate(item, "implementation_complete", []); + + result.IsValid.ShouldBeFalse(); + result.TargetState.ShouldBe("Done"); + } + + [Fact] + public void Validate_AllChildrenComplete_FirstChildNotComplete_FailsFast() + { + var item = new WorkItemBuilder() + .WithId(1).WithType("Epic").WithState("Doing").Build(); + var child1 = new WorkItemBuilder() + .WithId(10).WithType("Issue").WithState("To Do").Build(); + var child2 = new WorkItemBuilder() + .WithId(11).WithType("Issue").WithState("Done").Build(); + + var result = CreateValidator().Validate(item, "all_children_complete", [child1, child2]); + + result.IsValid.ShouldBeFalse(); + result.Message!.ShouldContain("child #10"); + } +} diff --git a/tests/Polyphony.Tests/TestFixtures/ProcessConfigBuilder.cs b/tests/Polyphony.Tests/TestFixtures/ProcessConfigBuilder.cs new file mode 100644 index 00000000..80007c93 --- /dev/null +++ b/tests/Polyphony.Tests/TestFixtures/ProcessConfigBuilder.cs @@ -0,0 +1,64 @@ +using Polyphony.Configuration; + +namespace Polyphony.Tests.TestFixtures; + +/// +/// Fluent builder for instances in Polyphony tests. +/// Starts with a minimal "Basic" template — add only the types and strategies each test needs. +/// +public sealed class ProcessConfigBuilder +{ + private string _processTemplate = "Basic"; + private readonly Dictionary _types = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _transitions = new(StringComparer.OrdinalIgnoreCase); + private BranchStrategy? _branchStrategy; + private string _platform = "github"; + + public ProcessConfigBuilder WithProcessTemplate(string template) { _processTemplate = template; return this; } + public ProcessConfigBuilder WithPlatform(string platform) { _platform = platform; return this; } + + /// + /// Adds a work item type with its capabilities and per-event transitions. + /// + public ProcessConfigBuilder WithType( + string name, + string[] capabilities, + Dictionary? transitions = null) + { + _types[name] = new TypeConfig { Capabilities = capabilities }; + + if (transitions is not null) + _transitions[name] = new Dictionary(transitions, StringComparer.OrdinalIgnoreCase); + + return this; + } + + /// Configures the branch naming strategy. + public ProcessConfigBuilder WithBranchStrategy( + string featureBranch = "feature/{id}", + string planningBranch = "planning/{id}", + string pgBranch = "feature/{id}-pg-{pg}", + string target = "main") + { + _branchStrategy = new BranchStrategy + { + FeatureBranch = featureBranch, + PlanningBranch = planningBranch, + PgBranch = pgBranch, + Target = target, + }; + return this; + } + + public ProcessConfig Build() + { + return new ProcessConfig + { + ProcessTemplate = _processTemplate, + Types = new Dictionary(_types, StringComparer.OrdinalIgnoreCase), + Transitions = new Dictionary>(_transitions, StringComparer.OrdinalIgnoreCase), + BranchStrategy = _branchStrategy, + Platform = _platform, + }; + } +} diff --git a/tests/Polyphony.Tests/TestFixtures/ProcessConfigBuilderTests.cs b/tests/Polyphony.Tests/TestFixtures/ProcessConfigBuilderTests.cs new file mode 100644 index 00000000..2f114b70 --- /dev/null +++ b/tests/Polyphony.Tests/TestFixtures/ProcessConfigBuilderTests.cs @@ -0,0 +1,114 @@ +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.TestFixtures; + +public sealed class ProcessConfigBuilderTests +{ + [Fact] + public void Build_Defaults_ProducesValidConfig() + { + var config = new ProcessConfigBuilder().Build(); + + config.ProcessTemplate.ShouldBe("Basic"); + config.Platform.ShouldBe("github"); + config.Types.ShouldBeEmpty(); + config.Transitions.ShouldBeEmpty(); + config.BranchStrategy.ShouldBeNull(); + } + + [Fact] + public void Build_WithType_AddsTypeAndTransitions() + { + var transitions = new Dictionary + { + ["begin_planning"] = "Doing", + ["complete"] = "Done", + }; + + var config = new ProcessConfigBuilder() + .WithType("Issue", ["plannable", "implementable"], transitions) + .Build(); + + config.Types.ShouldContainKey("Issue"); + config.Types["Issue"].Capabilities.ShouldBe(new[] { "plannable", "implementable" }); + config.Transitions.ShouldContainKey("Issue"); + config.Transitions["Issue"]["begin_planning"].ShouldBe("Doing"); + config.Transitions["Issue"]["complete"].ShouldBe("Done"); + } + + [Fact] + public void Build_WithTypeNoTransitions_AddsTypeOnly() + { + var config = new ProcessConfigBuilder() + .WithType("Task", ["implementable"]) + .Build(); + + config.Types.ShouldContainKey("Task"); + config.Types["Task"].Capabilities.ShouldBe(new[] { "implementable" }); + config.Transitions.ShouldNotContainKey("Task"); + } + + [Fact] + public void Build_MultipleTypes_AddsAll() + { + var config = new ProcessConfigBuilder() + .WithType("Epic", ["plannable"]) + .WithType("Task", ["implementable"]) + .Build(); + + config.Types.Count.ShouldBe(2); + config.Types.ShouldContainKey("Epic"); + config.Types.ShouldContainKey("Task"); + } + + [Fact] + public void Build_WithBranchStrategy_SetsAllFields() + { + var config = new ProcessConfigBuilder() + .WithBranchStrategy( + featureBranch: "feature/{id}", + planningBranch: "planning/{id}", + pgBranch: "feature/{id}-pg-{pg}", + target: "develop") + .Build(); + + config.BranchStrategy.ShouldNotBeNull(); + config.BranchStrategy!.FeatureBranch.ShouldBe("feature/{id}"); + config.BranchStrategy.PlanningBranch.ShouldBe("planning/{id}"); + config.BranchStrategy.PgBranch.ShouldBe("feature/{id}-pg-{pg}"); + config.BranchStrategy.Target.ShouldBe("develop"); + } + + [Fact] + public void Build_WithBranchStrategy_DefaultValues() + { + var config = new ProcessConfigBuilder() + .WithBranchStrategy() + .Build(); + + config.BranchStrategy.ShouldNotBeNull(); + config.BranchStrategy!.Target.ShouldBe("main"); + } + + [Fact] + public void Build_WithProcessTemplate_SetsTemplate() + { + var config = new ProcessConfigBuilder() + .WithProcessTemplate("Agile") + .Build(); + + config.ProcessTemplate.ShouldBe("Agile"); + } + + [Fact] + public void Build_WithPlatform_SetsPlatform() + { + var config = new ProcessConfigBuilder() + .WithPlatform("azure-devops") + .Build(); + + config.Platform.ShouldBe("azure-devops"); + } +} diff --git a/tests/Polyphony.Tests/TestFixtures/WorkItemBuilder.cs b/tests/Polyphony.Tests/TestFixtures/WorkItemBuilder.cs new file mode 100644 index 00000000..7371f75d --- /dev/null +++ b/tests/Polyphony.Tests/TestFixtures/WorkItemBuilder.cs @@ -0,0 +1,74 @@ +using Twig.Domain.Aggregates; +using Twig.Domain.ValueObjects; + +namespace Polyphony.Tests.TestFixtures; + +/// +/// Fluent builder for instances in Polyphony tests. +/// Defaults to type Issue in state "To Do" — override only what matters for each test. +/// +public sealed class WorkItemBuilder +{ + private int _id = 1; + private string _type = "Issue"; + private string _title = "Test Item"; + private string _state = "To Do"; + private int? _parentId; + private readonly List _children = []; + + public WorkItemBuilder WithId(int id) { _id = id; return this; } + public WorkItemBuilder WithTitle(string title) { _title = title; return this; } + public WorkItemBuilder WithType(string type) { _type = type; return this; } + public WorkItemBuilder WithState(string state) { _state = state; return this; } + public WorkItemBuilder WithParentId(int? parentId) { _parentId = parentId; return this; } + + /// + /// Registers child builders whose items will have their ParentId set to this builder's Id. + /// Use to materialise the parent and all children. + /// + public WorkItemBuilder WithChildren(params WorkItemBuilder[] children) + { + _children.AddRange(children); + return this; + } + + /// Builds a single (ignores registered children). + public WorkItem Build() + { + var parsedType = WorkItemType.Parse(_type); + if (!parsedType.IsSuccess) + throw new InvalidOperationException($"Invalid work item type '{_type}': {parsedType.Error}"); + + var item = new WorkItem + { + Id = _id, + Type = parsedType.Value, + Title = _title, + ParentId = _parentId, + }; + + // State has internal set — use the public ChangeState() then clear dirty via MarkSynced(). + item.ChangeState(_state); + item.MarkSynced(1); + + return item; + } + + /// + /// Builds this item as the root plus all registered children. + /// Each child's ParentId is forced to this builder's Id. + /// + public (WorkItem Root, IReadOnlyList Children) BuildAll() + { + var root = Build(); + var children = new List(_children.Count); + + foreach (var childBuilder in _children) + { + childBuilder.WithParentId(_id); + children.Add(childBuilder.Build()); + } + + return (root, children); + } +} diff --git a/tests/Polyphony.Tests/TestFixtures/WorkItemBuilderTests.cs b/tests/Polyphony.Tests/TestFixtures/WorkItemBuilderTests.cs new file mode 100644 index 00000000..d43047e1 --- /dev/null +++ b/tests/Polyphony.Tests/TestFixtures/WorkItemBuilderTests.cs @@ -0,0 +1,93 @@ +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Twig.Domain.ValueObjects; +using Xunit; + +namespace Polyphony.Tests.TestFixtures; + +public sealed class WorkItemBuilderTests +{ + [Fact] + public void Build_Defaults_ProducesIssueInToDo() + { + var item = new WorkItemBuilder().Build(); + + item.Id.ShouldBe(1); + item.Title.ShouldBe("Test Item"); + item.Type.ShouldBe(WorkItemType.Issue); + item.State.ShouldBe("To Do"); + item.ParentId.ShouldBeNull(); + item.IsDirty.ShouldBeFalse(); + } + + [Fact] + public void Build_FullyConfigured_SetsAllProperties() + { + var item = new WorkItemBuilder() + .WithId(42) + .WithTitle("Login endpoint") + .WithType("Epic") + .WithState("Doing") + .WithParentId(100) + .Build(); + + item.Id.ShouldBe(42); + item.Title.ShouldBe("Login endpoint"); + item.Type.ShouldBe(WorkItemType.Epic); + item.State.ShouldBe("Doing"); + item.ParentId.ShouldBe(100); + } + + [Fact] + public void Build_WithType_ParsesKnownTypes() + { + new WorkItemBuilder().WithType("Task").Build().Type.ShouldBe(WorkItemType.Task); + new WorkItemBuilder().WithType("Bug").Build().Type.ShouldBe(WorkItemType.Bug); + new WorkItemBuilder().WithType("Epic").Build().Type.ShouldBe(WorkItemType.Epic); + new WorkItemBuilder().WithType("Feature").Build().Type.ShouldBe(WorkItemType.Feature); + } + + [Fact] + public void Build_InvalidType_Throws() + { + Should.Throw( + () => new WorkItemBuilder().WithType("").Build()); + } + + [Fact] + public void Build_NullParentId_IsNull() + { + var item = new WorkItemBuilder().WithParentId(null).Build(); + + item.ParentId.ShouldBeNull(); + } + + [Fact] + public void BuildAll_CreatesRootAndChildren() + { + var child1 = new WorkItemBuilder().WithId(10).WithTitle("Child 1").WithType("Task"); + var child2 = new WorkItemBuilder().WithId(11).WithTitle("Child 2").WithType("Task"); + + var (root, children) = new WorkItemBuilder() + .WithId(1) + .WithType("Issue") + .WithChildren(child1, child2) + .BuildAll(); + + root.Id.ShouldBe(1); + children.Count.ShouldBe(2); + children[0].ParentId.ShouldBe(1); + children[1].ParentId.ShouldBe(1); + children[0].Id.ShouldBe(10); + children[1].Id.ShouldBe(11); + } + + [Fact] + public void BuildAll_NoChildren_ReturnsEmptyList() + { + var (root, children) = new WorkItemBuilder().WithId(5).BuildAll(); + + root.Id.ShouldBe(5); + children.ShouldBeEmpty(); + } +}