diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index 40e1b611..837d68ed 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -586,6 +586,142 @@ def serve( no_args_is_help=True, ) +# PRD templates subcommand group +prd_templates_app = typer.Typer( + name="templates", + help="PRD template management for customizable output formats", + no_args_is_help=True, +) + + +@prd_templates_app.command("list") +def prd_templates_list() -> None: + """List available PRD templates. + + Shows all built-in and custom PRD templates that can be used + with 'codeframe prd generate --template'. + + Example: + codeframe prd templates list + """ + from codeframe.planning.prd_templates import PrdTemplateManager + + # Pass workspace path to include project templates + manager = PrdTemplateManager(workspace_path=Path.cwd()) + templates = manager.list_templates() + + console.print("\n[bold]Available PRD Templates:[/bold]\n") + for template in templates: + section_count = len(template.sections) + console.print(f" [green]{template.id}[/green] - {template.name}") + console.print(f" {template.description}") + console.print(f" Sections: {section_count} | Version: {template.version}") + console.print() + + +@prd_templates_app.command("show") +def prd_templates_show( + template_id: str = typer.Argument(..., help="Template ID to show"), +) -> None: + """Show details of a specific PRD template. + + Displays the template's sections and their configuration. + + Example: + codeframe prd templates show standard + codeframe prd templates show lean + """ + from codeframe.planning.prd_templates import PrdTemplateManager + + # Pass workspace path to include project templates + manager = PrdTemplateManager(workspace_path=Path.cwd()) + template = manager.get_template(template_id) + + if not template: + console.print(f"[red]Error:[/red] Template '{template_id}' not found.") + console.print("\nAvailable templates:") + for t in manager.list_templates(): + console.print(f" {t.id}") + raise typer.Exit(1) + + console.print(f"\n[bold]{template.name}[/bold] ({template.id})\n") + console.print(f"{template.description}\n") + console.print(f"Version: {template.version}") + + console.print("\n[bold]Sections:[/bold]\n") + for i, section in enumerate(template.sections, 1): + required = "[green]required[/green]" if section.required else "[dim]optional[/dim]" + console.print(f" {i}. {section.title} ({section.id})") + console.print(f" Source: {section.source} | {required}") + console.print() + + +@prd_templates_app.command("export") +def prd_templates_export( + template_id: str = typer.Argument(..., help="Template ID to export"), + output_path: Path = typer.Argument(..., help="Output file path (.yaml)"), +) -> None: + """Export a PRD template to a YAML file. + + Exports the template configuration for backup or customization. + + Example: + codeframe prd templates export standard ./my-template.yaml + codeframe prd templates export enterprise ./enterprise-prd.yaml + """ + from codeframe.planning.prd_templates import PrdTemplateManager + + # Pass workspace path to include project templates + manager = PrdTemplateManager(workspace_path=Path.cwd()) + + try: + manager.export_template(template_id, output_path) + console.print(f"[green]✓[/green] Exported template '{template_id}' to {output_path}") + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + console.print("\nAvailable templates:") + for t in manager.list_templates(): + console.print(f" {t.id}") + raise typer.Exit(1) + + +@prd_templates_app.command("import") +def prd_templates_import( + source_path: Path = typer.Argument( + ..., + help="Path to template YAML file", + exists=True, + file_okay=True, + dir_okay=False, + ), +) -> None: + """Import a PRD template from a YAML file. + + Imports a custom template that can be used with 'codeframe prd generate --template'. + The template is saved to the project's .codeframe/templates/prd/ directory. + + Example: + codeframe prd templates import ./custom-template.yaml + """ + from codeframe.planning.prd_templates import PrdTemplateManager + + # Pass workspace path for project template storage + manager = PrdTemplateManager(workspace_path=Path.cwd()) + + try: + # Import and persist to project directory + template = manager.import_template(source_path, persist=True) + console.print(f"[green]✓[/green] Imported template '{template.id}' ({template.name})") + console.print(f"[dim]Sections: {len(template.sections)}[/dim]") + console.print(f"[dim]Saved to: .codeframe/templates/prd/{template.id}.yaml[/dim]") + except Exception as e: + console.print(f"[red]Error:[/red] Failed to import template: {e}") + raise typer.Exit(1) + + +# Register prd_templates_app under prd_app +prd_app.add_typer(prd_templates_app, name="templates") + @prd_app.command("add") def prd_add( @@ -1104,6 +1240,11 @@ def prd_generate( "--resume", "-r", help="Resume from a paused session (blocker ID)", ), + template: str = typer.Option( + "standard", + "--template", "-t", + help="PRD template to use (standard, lean, enterprise, user-story-map, technical-spec)", + ), ) -> None: """Generate a PRD through AI-driven Socratic discovery. @@ -1121,10 +1262,18 @@ def prd_generate( /quit - Exit without saving /help - Show available commands + Use --template to select the output format: + - standard: Full PRD with all sections (default) + - lean: Minimal PRD with problem, users, MVP features + - enterprise: Formal PRD with compliance and traceability + - user-story-map: Organized around user journeys + - technical-spec: Focused on technical specifications + Requires ANTHROPIC_API_KEY environment variable. Example: codeframe prd generate + codeframe prd generate --template lean codeframe prd generate --resume abc123 """ from codeframe.core.workspace import get_workspace @@ -1137,11 +1286,24 @@ def prd_generate( get_active_session, ) from codeframe.core.events import emit_for_workspace, EventType + from codeframe.planning.prd_templates import PrdTemplateManager from rich.panel import Panel from rich.prompt import Prompt workspace_path = repo_path or Path.cwd() + # Validate template exists (include project templates in search) + template_manager = PrdTemplateManager(workspace_path=workspace_path) + template_obj = template_manager.get_template(template) + if template_obj is None: + console.print(f"[red]Error:[/red] Template '{template}' not found.") + console.print("\nAvailable templates:") + for t in template_manager.list_templates(): + console.print(f" {t.id} - {t.name}") + raise typer.Exit(1) + + console.print(f"[dim]Using template: {template_obj.name}[/dim]") + try: workspace = get_workspace(workspace_path) @@ -1272,10 +1434,10 @@ def prd_generate( # Generate PRD console.print("\n[bold green]Discovery complete![/bold green]") - console.print("\nGenerating PRD from our conversation...") + console.print(f"\nGenerating PRD using '{template}' template...") try: - prd_record = session.generate_prd() + prd_record = session.generate_prd(template_id=template) except IncompleteSessionError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) diff --git a/codeframe/core/prd_discovery.py b/codeframe/core/prd_discovery.py index b78b7c85..a6dbc546 100644 --- a/codeframe/core/prd_discovery.py +++ b/codeframe/core/prd_discovery.py @@ -589,11 +589,16 @@ def resume_discovery(self, blocker_id: str) -> None: logger.info(f"Resumed session {session_id} from blocker {blocker_id}") - def generate_prd(self) -> prd.PrdRecord: + def generate_prd(self, template_id: Optional[str] = None) -> prd.PrdRecord: """Generate PRD from discovery conversation. Uses AI to synthesize the conversation into a structured PRD. + Args: + template_id: Optional PRD template ID to use for formatting. + If not provided or not found, uses the default built-in + prompt format (recorded as "default" in metadata). + Returns: Created PrdRecord @@ -606,10 +611,10 @@ def generate_prd(self) -> prd.PrdRecord: f"Current coverage: {self._coverage}" ) - qa_history = self._format_qa_history() - prompt = PRD_GENERATION_PROMPT.format(qa_history=qa_history) + # Build prompt based on template and get the resolved template ID + prompt, resolved_template_id = self._build_prd_prompt(qa_history, template_id) response = self._llm_provider.complete( messages=[{"role": "user", "content": prompt}], @@ -623,27 +628,93 @@ def generate_prd(self) -> prd.PrdRecord: # Extract title from PRD content title = self._extract_title_from_prd(content) - # Store PRD + # Store PRD with both requested and resolved template IDs in metadata + metadata: dict[str, Any] = { + "source": "ai_discovery", + "session_id": self.session_id, + "questions_asked": len(self._qa_history), + "coverage": self._coverage, + "generated_at": _utc_now().isoformat(), + "template_id": resolved_template_id, + } + # Track if a different template was requested but not found + if template_id and template_id != resolved_template_id: + metadata["requested_template_id"] = template_id + record = prd.store( self.workspace, content=content, title=title, - metadata={ - "source": "ai_discovery", - "session_id": self.session_id, - "questions_asked": len(self._qa_history), - "coverage": self._coverage, - "generated_at": _utc_now().isoformat(), - }, + metadata=metadata, ) # Update session state self.state = SessionState.COMPLETED self._save_session() - logger.info(f"Generated PRD {record.id} from session {self.session_id}") + logger.info(f"Generated PRD {record.id} from session {self.session_id} using template '{resolved_template_id}'") return record + def _build_prd_prompt( + self, qa_history: str, template_id: Optional[str] = None + ) -> tuple[str, str]: + """Build PRD generation prompt based on template. + + Args: + qa_history: Formatted Q&A history string + template_id: Template ID to use (defaults to None, which uses default prompt) + + Returns: + Tuple of (prompt string, resolved template ID) + The resolved template ID is "default" if no template was used, + or the actual template ID that was successfully loaded. + """ + from codeframe.planning.prd_templates import PrdTemplateManager + from pathlib import Path + + # Use default prompt if no template specified + if not template_id: + return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default") + + # Pass workspace path to include project templates + workspace_path = Path(self.workspace.repo_path) if self.workspace.repo_path else None + manager = PrdTemplateManager(workspace_path=workspace_path) + template = manager.get_template(template_id) + + if template is None: + logger.warning(f"Template '{template_id}' not found, falling back to default prompt") + return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default") + + # Build dynamic prompt from template sections + sections_spec = [] + for section in template.sections: + required_note = " (required)" if section.required else " (optional)" + sections_spec.append(f"## {section.title}{required_note}\n{section.source} - related content") + + sections_text = "\n\n".join(sections_spec) + + prompt = f"""Generate a Product Requirements Document based on the discovery conversation. + +## Discovery Conversation +{qa_history} + +## Template: {template.name} +{template.description} + +## Sections +Generate a markdown PRD with these sections in order: + +# [Project Title - infer from conversation] + +{sections_text} + +--- + +Keep it concise but complete. Focus on actionable requirements. +Follow the template structure exactly. This PRD should be sufficient to generate development tasks.""" + + return (prompt, template_id) + def _extract_title_from_prd(self, content: str) -> str: """Extract project title from generated PRD content.""" import re diff --git a/codeframe/planning/__init__.py b/codeframe/planning/__init__.py index a69aa988..d697fe4c 100644 --- a/codeframe/planning/__init__.py +++ b/codeframe/planning/__init__.py @@ -4,6 +4,7 @@ - Issue generation from PRD features - Task decomposition from issues - Work breakdown planning +- PRD template system for customizable output formats """ from codeframe.planning.issue_generator import ( @@ -11,9 +12,19 @@ parse_prd_features, assign_priority, ) +from codeframe.planning.prd_templates import ( + PrdTemplate, + PrdTemplateSection, + PrdTemplateManager, + BUILTIN_TEMPLATES, +) __all__ = [ "IssueGenerator", "parse_prd_features", "assign_priority", + "PrdTemplate", + "PrdTemplateSection", + "PrdTemplateManager", + "BUILTIN_TEMPLATES", ] diff --git a/codeframe/planning/prd_template_functions.py b/codeframe/planning/prd_template_functions.py new file mode 100644 index 00000000..7dbfff29 --- /dev/null +++ b/codeframe/planning/prd_template_functions.py @@ -0,0 +1,137 @@ +"""Jinja2 template functions for PRD rendering. + +This module provides custom filters and functions for use in PRD templates: +- bullet_list: Convert list to markdown bullets +- numbered_list: Convert list to numbered list +- table: Generate markdown table +- summarize: Summarize long text (placeholder for LLM call) +""" + +from typing import Any + + +def bullet_list(items: list[str]) -> str: + """Convert a list to markdown bullet points. + + Args: + items: List of strings to convert + + Returns: + Markdown formatted bullet list + """ + if not items: + return "" + return "\n".join(f"- {item}" for item in items) + + +def numbered_list(items: list[str]) -> str: + """Convert a list to a numbered markdown list. + + Args: + items: List of strings to convert + + Returns: + Markdown formatted numbered list + """ + if not items: + return "" + return "\n".join(f"{i}. {item}" for i, item in enumerate(items, 1)) + + +def table(items: list[dict[str, Any]], columns: list[str]) -> str: + """Generate a markdown table from a list of dictionaries. + + Args: + items: List of dictionaries with data + columns: Column names to include in table + + Returns: + Markdown formatted table + """ + if not items or not columns: + return "" + + # Header row + header = "| " + " | ".join(columns) + " |" + + # Separator row + separator = "| " + " | ".join("---" for _ in columns) + " |" + + # Data rows + rows = [] + for item in items: + row_values = [str(item.get(col, "")) for col in columns] + rows.append("| " + " | ".join(row_values) + " |") + + return "\n".join([header, separator] + rows) + + +def summarize(text: str, max_words: int = 50) -> str: + """Summarize long text (placeholder - truncates for now). + + In a future version, this could call an LLM for proper summarization. + + Args: + text: Text to summarize + max_words: Maximum words in summary + + Returns: + Summarized text + """ + if not text: + return "" + + words = text.split() + if len(words) <= max_words: + return text + + return " ".join(words[:max_words]) + "..." + + +def join_list(items: list[str], separator: str = ", ") -> str: + """Join a list into a string with separator. + + Args: + items: List of strings to join + separator: Separator between items + + Returns: + Joined string + """ + if not items: + return "" + return separator.join(str(item) for item in items) + + +def format_constraints(constraints: dict[str, Any]) -> str: + """Format constraints dictionary as markdown. + + Args: + constraints: Dictionary of constraint types to values + + Returns: + Markdown formatted constraints + """ + if not constraints: + return "No specific constraints defined." + + lines = [] + for key, value in constraints.items(): + if isinstance(value, list): + value_str = ", ".join(str(v) for v in value) + else: + value_str = str(value) + lines.append(f"- **{key.title()}**: {value_str}") + + return "\n".join(lines) + + +# Registry of all template functions +TEMPLATE_FUNCTIONS = { + "bullet_list": bullet_list, + "numbered_list": numbered_list, + "table": table, + "summarize": summarize, + "join_list": join_list, + "format_constraints": format_constraints, +} diff --git a/codeframe/planning/prd_templates.py b/codeframe/planning/prd_templates.py new file mode 100644 index 00000000..6ebe87ec --- /dev/null +++ b/codeframe/planning/prd_templates.py @@ -0,0 +1,975 @@ +"""PRD Template System for CodeFRAME. + +This module provides a template system for customizable PRD output formats: +- PrdTemplateSection: Represents a single section with rendering template +- PrdTemplate: Contains template metadata and list of sections +- PrdTemplateManager: Manage and render templates +- BUILTIN_TEMPLATES: Predefined templates (standard, lean, enterprise, etc.) +""" + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +import yaml +from jinja2 import Environment, BaseLoader, TemplateSyntaxError + +from codeframe.planning.prd_template_functions import TEMPLATE_FUNCTIONS + +logger = logging.getLogger(__name__) + + +@dataclass +class PrdTemplateSection: + """Individual section within a PRD template. + + Attributes: + id: Unique identifier for the section + title: Human-readable section title + source: Discovery data category to draw from (problem, users, features, etc.) + format_template: Jinja2 template string for rendering + required: Whether this section must be included (default: True) + """ + + id: str + title: str + source: str + format_template: str + required: bool = True + + +@dataclass +class PrdTemplate: + """Template for generating a PRD. + + Attributes: + id: Unique identifier for the template + name: Human-readable name + version: Template version number + description: Description of when to use this template + sections: List of PrdTemplateSection objects + """ + + id: str + name: str + version: int + description: str + sections: list[PrdTemplateSection] + + @property + def section_ids(self) -> list[str]: + """Get list of section IDs.""" + return [s.id for s in self.sections] + + +# Built-in templates +BUILTIN_TEMPLATES: list[PrdTemplate] = [ + PrdTemplate( + id="standard", + name="Standard PRD", + version=1, + description="Default PRD format with executive summary, problem statement, " + "user personas, features, technical architecture, success metrics, and timeline.", + sections=[ + PrdTemplateSection( + id="executive_summary", + title="Executive Summary", + source="problem", + format_template="""## Executive Summary + +{{ problem | default('Project overview not yet defined.') }} +""", + ), + PrdTemplateSection( + id="problem_statement", + title="Problem Statement", + source="problem", + format_template="""## Problem Statement + +{{ problem | default('Problem statement not yet defined.') }} + +### Business Justification + +This solution addresses a critical need in the target market by solving the core problem outlined above. +""", + ), + PrdTemplateSection( + id="user_personas", + title="User Personas", + source="users", + format_template="""## User Personas + +### Target Users + +{{ users | bullet_list if users else 'User personas not yet defined.' }} +""", + ), + PrdTemplateSection( + id="features", + title="Features & Requirements", + source="features", + format_template="""## Features & Requirements + +### Core Features + +{{ features | numbered_list if features else 'Features not yet defined.' }} + +### Functional Requirements + +Each feature listed above represents a functional requirement that must be implemented to meet user needs. +""", + ), + PrdTemplateSection( + id="technical_architecture", + title="Technical Architecture", + source="tech_stack", + format_template="""## Technical Architecture + +### Technology Stack + +{{ tech_stack | bullet_list if tech_stack else 'Technology stack not yet defined.' }} + +### Constraints + +{{ constraints | format_constraints if constraints else 'No specific constraints defined.' }} +""", + ), + PrdTemplateSection( + id="success_metrics", + title="Success Metrics", + source="features", + format_template="""## Success Metrics + +### Key Performance Indicators + +- User adoption rate +- Feature completion rate +- System performance metrics +- User satisfaction scores + +### Success Criteria + +The project will be considered successful when all core features are implemented and user adoption targets are met. +""", + ), + PrdTemplateSection( + id="timeline", + title="Timeline & Milestones", + source="features", + format_template="""## Timeline & Milestones + +### Project Phases + +1. **Discovery & Planning** - Requirements gathering and architecture design +2. **Development Phase 1** - Core feature implementation +3. **Development Phase 2** - Additional features and refinements +4. **Testing & QA** - Comprehensive testing and bug fixes +5. **Launch** - Deployment and user onboarding + +### Milestones + +Specific dates and milestones to be determined based on team capacity and priorities. +""", + ), + ], + ), + PrdTemplate( + id="lean", + name="Lean PRD", + version=1, + description="Minimal viable PRD with only problem, users, and MVP features. " + "Best for quick iterations and early-stage projects.", + sections=[ + PrdTemplateSection( + id="problem", + title="Problem", + source="problem", + format_template="""## Problem + +{{ problem | default('Problem not yet defined.') }} +""", + ), + PrdTemplateSection( + id="users", + title="Target Users", + source="users", + format_template="""## Target Users + +{{ users | bullet_list if users else 'Users not yet defined.' }} +""", + ), + PrdTemplateSection( + id="mvp_features", + title="MVP Features", + source="features", + format_template="""## MVP Features + +{{ features | numbered_list if features else 'Features not yet defined.' }} +""", + ), + ], + ), + PrdTemplate( + id="enterprise", + name="Enterprise PRD", + version=1, + description="Full formal PRD with compliance sections, traceability matrix, " + "stakeholder analysis, and risk assessment. Best for large organizations.", + sections=[ + PrdTemplateSection( + id="executive_summary", + title="Executive Summary", + source="problem", + format_template="""## Executive Summary + +### Overview + +{{ problem | default('Project overview not yet defined.') }} + +### Document Purpose + +This Product Requirements Document (PRD) defines the requirements, scope, and specifications for the project. +""", + ), + PrdTemplateSection( + id="stakeholder_analysis", + title="Stakeholder Analysis", + source="users", + format_template="""## Stakeholder Analysis + +### Primary Stakeholders + +{{ users | bullet_list if users else 'Stakeholders not yet defined.' }} + +### Stakeholder Responsibilities + +| Stakeholder | Role | Responsibility | +|-------------|------|----------------| +| Product Owner | Decision Maker | Final approval on requirements | +| Development Team | Implementer | Technical implementation | +| QA Team | Validator | Quality assurance | +""", + ), + PrdTemplateSection( + id="problem_statement", + title="Problem Statement", + source="problem", + format_template="""## Problem Statement + +### Current State + +{{ problem | default('Current state analysis not yet completed.') }} + +### Desired State + +The solution will address the identified problems and deliver value to stakeholders. + +### Gap Analysis + +Detailed gap analysis between current and desired states to be documented during discovery. +""", + ), + PrdTemplateSection( + id="user_personas", + title="User Personas", + source="users", + format_template="""## User Personas + +{{ users | bullet_list if users else 'User personas not yet defined.' }} +""", + ), + PrdTemplateSection( + id="requirements", + title="Requirements", + source="features", + format_template="""## Requirements + +### Functional Requirements + +{% if features %} +| ID | Requirement | Priority | Source | +|----|-------------|----------|--------| +{% for feature in features %} +| FR-{{ loop.index }} | {{ feature }} | P1 | Discovery | +{% endfor %} +{% else %} +Requirements not yet defined. +{% endif %} + +### Non-Functional Requirements + +| ID | Category | Requirement | +|----|----------|-------------| +| NFR-1 | Performance | System response time < 2 seconds | +| NFR-2 | Availability | 99.9% uptime SLA | +| NFR-3 | Security | Data encryption at rest and in transit | +""", + ), + PrdTemplateSection( + id="technical_architecture", + title="Technical Architecture", + source="tech_stack", + format_template="""## Technical Architecture + +### Technology Stack + +{{ tech_stack | bullet_list if tech_stack else 'Technology stack not yet defined.' }} + +### System Constraints + +{{ constraints | format_constraints if constraints else 'No specific constraints defined.' }} + +### Integration Points + +Integration requirements to be documented during technical design phase. +""", + ), + PrdTemplateSection( + id="risk_assessment", + title="Risk Assessment", + source="constraints", + format_template="""## Risk Assessment + +### Identified Risks + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Technical complexity | High | Medium | Phased implementation | +| Resource constraints | Medium | Medium | Priority-based scheduling | +| Scope creep | High | High | Strict change control | + +### Compliance Considerations + +{{ constraints | format_constraints if constraints else 'Compliance requirements to be documented.' }} +""", + required=False, + ), + PrdTemplateSection( + id="success_metrics", + title="Success Metrics", + source="features", + format_template="""## Success Metrics + +### Key Performance Indicators + +| KPI | Target | Measurement Method | +|-----|--------|-------------------| +| User Adoption | 80% | Active user tracking | +| Feature Completion | 100% | Sprint tracking | +| Defect Rate | < 5% | Bug tracking | +| User Satisfaction | > 4.0/5.0 | User surveys | +""", + ), + PrdTemplateSection( + id="timeline", + title="Timeline & Milestones", + source="features", + format_template="""## Timeline & Milestones + +### Project Phases + +| Phase | Description | Duration | +|-------|-------------|----------| +| Discovery | Requirements gathering | 2 weeks | +| Design | Technical architecture | 2 weeks | +| Development | Implementation | 8 weeks | +| Testing | QA and UAT | 2 weeks | +| Deployment | Production release | 1 week | + +### Approval Gates + +| Gate | Criteria | Approver | +|------|----------|----------| +| Requirements Sign-off | All requirements documented | Product Owner | +| Design Approval | Architecture approved | Tech Lead | +| Release Approval | All tests passing | QA Lead | +""", + ), + PrdTemplateSection( + id="traceability", + title="Traceability Matrix", + source="features", + format_template="""## Traceability Matrix + +This section maps requirements to test cases and implementation components. + +| Requirement ID | Feature | Test Case | Component | +|----------------|---------|-----------|-----------| +{% if features %} +{% for feature in features %} +| FR-{{ loop.index }} | {{ feature }} | TC-{{ loop.index }} | TBD | +{% endfor %} +{% else %} +| - | Requirements not yet defined | - | - | +{% endif %} +""", + required=False, + ), + ], + ), + PrdTemplate( + id="user-story-map", + name="User Story Map PRD", + version=1, + description="Organized around user journeys with story mapping structure. " + "Best for agile teams focused on user experience.", + sections=[ + PrdTemplateSection( + id="overview", + title="Overview", + source="problem", + format_template="""## Overview + +{{ problem | default('Project overview not yet defined.') }} +""", + ), + PrdTemplateSection( + id="user_activities", + title="User Activities", + source="users", + format_template="""## User Activities + +### Primary Users + +{{ users | bullet_list if users else 'Users not yet defined.' }} + +### User Goals + +Users want to accomplish specific goals efficiently and effectively. +""", + ), + PrdTemplateSection( + id="user_stories", + title="User Stories", + source="features", + format_template="""## User Stories + +{% if features %} +{% for feature in features %} +### Story {{ loop.index }}: {{ feature }} + +**As a** user +**I want to** {{ feature }} +**So that** I can accomplish my goals effectively + +**Acceptance Criteria:** +- [ ] Feature is implemented as specified +- [ ] Feature is tested and working +- [ ] Feature is documented + +{% endfor %} +{% else %} +User stories not yet defined. +{% endif %} +""", + ), + PrdTemplateSection( + id="release_plan", + title="Release Plan", + source="features", + format_template="""## Release Plan + +### MVP (Release 1) + +Core features to be included in initial release. + +{{ features | bullet_list if features else 'Features not yet prioritized.' }} + +### Future Releases + +Additional features and enhancements for subsequent releases. +""", + ), + ], + ), + PrdTemplate( + id="technical-spec", + name="Technical Specification", + version=1, + description="Focused on technical requirements, architecture diagrams, " + "API specifications, and data models. Best for technical audiences.", + sections=[ + PrdTemplateSection( + id="overview", + title="Technical Overview", + source="problem", + format_template="""## Technical Overview + +### Purpose + +{{ problem | default('Technical purpose not yet defined.') }} + +### Scope + +This document defines the technical specifications for the system. +""", + ), + PrdTemplateSection( + id="architecture", + title="System Architecture", + source="tech_stack", + format_template="""## System Architecture + +### Technology Stack + +{{ tech_stack | bullet_list if tech_stack else 'Technology stack not yet defined.' }} + +### Architecture Overview + +``` ++-------------------+ +| Frontend UI | ++-------------------+ + | ++-------------------+ +| API Gateway | ++-------------------+ + | ++-------------------+ +| Business Logic | ++-------------------+ + | ++-------------------+ +| Data Layer | ++-------------------+ +``` + +### Component Diagram + +Components to be detailed during technical design. +""", + ), + PrdTemplateSection( + id="api_specification", + title="API Specification", + source="features", + format_template="""## API Specification + +### Endpoints + +{% if features %} +| Endpoint | Method | Description | +|----------|--------|-------------| +{% for feature in features %} +| /api/{{ feature | lower | replace(' ', '-') }} | GET/POST | {{ feature }} | +{% endfor %} +{% else %} +API endpoints not yet defined. +{% endif %} + +### Authentication + +API authentication method to be determined. + +### Rate Limiting + +Rate limiting policies to be defined based on usage patterns. +""", + ), + PrdTemplateSection( + id="data_models", + title="Data Models", + source="features", + format_template="""## Data Models + +### Entity Relationship + +Core entities and their relationships to be documented. + +### Database Schema + +Schema design to be completed during technical design phase. + +{{ constraints | format_constraints if constraints else 'Database constraints not yet defined.' }} +""", + ), + PrdTemplateSection( + id="security", + title="Security Considerations", + source="constraints", + format_template="""## Security Considerations + +### Authentication & Authorization + +- Authentication method: TBD +- Authorization model: Role-based access control (RBAC) + +### Data Protection + +- Encryption at rest +- Encryption in transit (TLS 1.3) +- Data retention policies + +### Compliance + +{{ constraints | format_constraints if constraints else 'Compliance requirements to be documented.' }} +""", + ), + PrdTemplateSection( + id="performance", + title="Performance Requirements", + source="constraints", + format_template="""## Performance Requirements + +### Response Time + +- API response time: < 200ms (p95) +- Page load time: < 2 seconds + +### Throughput + +- Target: 1000 requests/second + +### Scalability + +- Horizontal scaling capability +- Auto-scaling based on load + +{{ constraints | format_constraints if constraints else 'Additional performance constraints to be defined.' }} +""", + ), + ], + ), +] + + +def get_global_template_dir() -> Path: + """Get the global PRD template directory. + + Returns: + Path to ~/.codeframe/templates/prd/ + """ + return Path.home() / ".codeframe" / "templates" / "prd" + + +def get_project_template_dir(workspace_path: Optional[Path] = None) -> Path: + """Get the project-level PRD template directory. + + Args: + workspace_path: Optional workspace path (defaults to current directory) + + Returns: + Path to .codeframe/templates/prd/ + """ + base = workspace_path or Path.cwd() + return base / ".codeframe" / "templates" / "prd" + + +def save_template_to_file(template: PrdTemplate, path: Path) -> None: + """Save a template to a YAML file. + + Args: + template: Template to save + path: File path to save to + """ + # Convert to dictionary for YAML serialization + data = { + "id": template.id, + "name": template.name, + "version": template.version, + "description": template.description, + "sections": [ + { + "id": s.id, + "title": s.title, + "source": s.source, + "format_template": s.format_template, + "required": s.required, + } + for s in template.sections + ], + } + + # Ensure parent directory exists + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True) + + logger.debug(f"Saved template to {path}") + + +def load_template_from_file(path: Path) -> PrdTemplate: + """Load a template from a YAML file. + + Args: + path: File path to load from + + Returns: + Loaded PrdTemplate + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If file is not a YAML file + yaml.YAMLError: If file is not valid YAML + """ + # Resolve to absolute path and validate + path = path.resolve() + if not path.is_file(): + raise FileNotFoundError(f"Template file not found: {path}") + + if path.suffix.lower() not in [".yaml", ".yml"]: + raise ValueError(f"Template must be a YAML file, got: {path.suffix}") + + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) + + if not data: + raise ValueError(f"Template file is empty or invalid: {path}") + + sections = [ + PrdTemplateSection( + id=s["id"], + title=s["title"], + source=s["source"], + format_template=s["format_template"], + required=s.get("required", True), + ) + for s in data.get("sections", []) + ] + + return PrdTemplate( + id=data["id"], + name=data["name"], + version=data.get("version", 1), + description=data.get("description", ""), + sections=sections, + ) + + +class PrdTemplateManager: + """Manager for PRD templates. + + Handles: + - Loading and storing templates + - Retrieving templates by ID + - Validating template structure + - Rendering templates with discovery data + - Importing/exporting templates + """ + + def __init__(self, workspace_path: Optional[Path] = None): + """Initialize with built-in templates. + + Args: + workspace_path: Optional workspace path for project templates + """ + self.templates: dict[str, PrdTemplate] = {} + self.workspace_path = workspace_path + + # Load built-in templates + for template in BUILTIN_TEMPLATES: + self.templates[template.id] = template + + # Load custom templates from global directory + self._load_from_directory(get_global_template_dir()) + + # Load project templates (override global) + if workspace_path: + self._load_from_directory(get_project_template_dir(workspace_path)) + + # Set up Jinja2 environment with autoescape for defense-in-depth + # PRDs are markdown but could be rendered to HTML downstream + self._env = Environment(loader=BaseLoader(), autoescape=True) + for name, func in TEMPLATE_FUNCTIONS.items(): + self._env.filters[name] = func + + logger.info(f"PrdTemplateManager initialized with {len(self.templates)} templates") + + def _load_from_directory(self, directory: Path) -> None: + """Load templates from a directory. + + Args: + directory: Directory containing YAML template files + """ + if not directory.exists(): + return + + # Support both .yaml and .yml extensions + for pattern in ("*.yaml", "*.yml"): + for path in directory.glob(pattern): + try: + template = load_template_from_file(path) + if template.id in self.templates: + existing = self.templates[template.id] + logger.warning( + f"Template '{template.id}' from {path} overrides " + f"existing template '{existing.name}'" + ) + self.templates[template.id] = template + logger.debug(f"Loaded template from {path}") + except Exception as e: + logger.warning(f"Failed to load template from {path}: {e}") + + def get_template(self, template_id: str) -> Optional[PrdTemplate]: + """Get a template by ID. + + Args: + template_id: Template identifier + + Returns: + PrdTemplate if found, None otherwise + """ + return self.templates.get(template_id) + + def list_templates(self) -> list[PrdTemplate]: + """List all available templates. + + Returns: + List of PrdTemplate objects sorted by name + """ + return sorted(self.templates.values(), key=lambda t: t.name or "") + + def validate_template(self, template: PrdTemplate) -> list[str]: + """Validate a template structure. + + Args: + template: Template to validate + + Returns: + List of validation error messages (empty if valid) + """ + errors = [] + + # Check required fields + if not template.id: + errors.append("Template ID is required") + + if not template.name: + errors.append("Template name is required") + + if not template.sections: + errors.append("Template must have at least one section") + + # Validate each section's Jinja2 syntax + for section in template.sections: + try: + self._env.from_string(section.format_template) + except TemplateSyntaxError as e: + errors.append(f"Section '{section.id}' has invalid Jinja2 syntax: {e}") + + return errors + + def render_template( + self, template: PrdTemplate, discovery_data: dict[str, Any] + ) -> str: + """Render a template with discovery data. + + Args: + template: Template to render + discovery_data: Discovery data dictionary with keys like + problem, users, features, constraints, tech_stack + + Returns: + Rendered PRD content as markdown string + """ + sections = [] + + for section in template.sections: + try: + jinja_template = self._env.from_string(section.format_template) + rendered = jinja_template.render(**discovery_data) + sections.append(rendered.strip()) + except TemplateSyntaxError as e: + logger.error(f"Template syntax error in section {section.id}: {e}") + sections.append(f"## {section.title}\n\n*Template syntax error: {e}*") + except (KeyboardInterrupt, SystemExit): + raise # Don't catch these + except Exception as e: + logger.error(f"Failed to render section {section.id}: {e}", exc_info=True) + sections.append(f"## {section.title}\n\n*Error rendering section. Check logs for details.*") + + # Join with double newlines for proper markdown separation + return "\n\n".join(sections) + + def import_template(self, source_path: Path, persist: bool = False) -> PrdTemplate: + """Import a template from a file. + + Args: + source_path: Path to YAML template file + persist: If True, save template to the project template directory + + Returns: + Imported template + + Raises: + FileNotFoundError: If source file doesn't exist + ValueError: If template validation fails + """ + template = load_template_from_file(source_path) + + # Validate before registering + errors = self.validate_template(template) + if errors: + raise ValueError(f"Invalid template: {', '.join(errors)}") + + self.templates[template.id] = template + + if persist: + self.persist_template(template) + + logger.info(f"Imported template: {template.id}") + return template + + def persist_template(self, template: PrdTemplate, to_project: bool = True) -> Path: + """Persist a template to disk. + + Saves the template to either the project template directory (.codeframe/templates/prd/) + or the global template directory (~/.codeframe/templates/prd/). + + Args: + template: Template to persist + to_project: If True, save to project directory; if False, save to global + + Returns: + Path where template was saved + + Raises: + ValueError: If to_project=True but no workspace_path configured + """ + if to_project: + if self.workspace_path is None: + raise ValueError( + "Cannot persist to project: no workspace_path configured. " + "Either set to_project=False or initialize PrdTemplateManager with workspace_path." + ) + target_dir = get_project_template_dir(self.workspace_path) + else: + target_dir = get_global_template_dir() + + # Ensure directory exists + target_dir.mkdir(parents=True, exist_ok=True) + + # Save template + output_path = target_dir / f"{template.id}.yaml" + save_template_to_file(template, output_path) + + logger.info(f"Persisted template '{template.id}' to {output_path}") + return output_path + + def export_template(self, template_id: str, output_path: Path) -> None: + """Export a template to a file. + + Args: + template_id: ID of template to export + output_path: Path to save the template + + Raises: + ValueError: If template not found + """ + template = self.get_template(template_id) + if template is None: + raise ValueError(f"Template '{template_id}' not found") + + save_template_to_file(template, output_path) + logger.info(f"Exported template '{template_id}' to {output_path}") + + def register_template(self, template: PrdTemplate) -> None: + """Register a custom template. + + Args: + template: Template to register + """ + self.templates[template.id] = template + logger.info(f"Registered template: {template.id}") diff --git a/pyproject.toml b/pyproject.toml index e18bcb19..0bdeca05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ dependencies = [ "python-jose[cryptography]>=3.4.0", "passlib[argon2]>=1.7.4", "keyring>=24.0.0", + "jinja2>=3.1.6", ] [project.optional-dependencies] diff --git a/tests/cli/test_prd_template_commands.py b/tests/cli/test_prd_template_commands.py new file mode 100644 index 00000000..001e6ec2 --- /dev/null +++ b/tests/cli/test_prd_template_commands.py @@ -0,0 +1,215 @@ +"""Tests for PRD template CLI commands. + +This module tests: +- cf prd templates list +- cf prd templates show +- cf prd templates import +- cf prd templates export +- cf prd generate --template +""" + +import pytest +from typer.testing import CliRunner + +from codeframe.cli.app import app + +runner = CliRunner() + +pytestmark = pytest.mark.v2 + + +class TestPrdTemplatesList: + """Tests for 'cf prd templates list' command.""" + + def test_list_templates_shows_builtins(self): + """List command shows built-in templates.""" + result = runner.invoke(app, ["prd", "templates", "list"]) + assert result.exit_code == 0 + assert "standard" in result.output.lower() + assert "lean" in result.output.lower() + assert "enterprise" in result.output.lower() + + def test_list_templates_shows_template_info(self): + """List command shows template names and descriptions.""" + result = runner.invoke(app, ["prd", "templates", "list"]) + assert result.exit_code == 0 + # Check for expected template information + assert "Standard PRD" in result.output or "standard" in result.output.lower() + + +class TestPrdTemplatesShow: + """Tests for 'cf prd templates show' command.""" + + def test_show_template_details(self): + """Show command displays template details.""" + result = runner.invoke(app, ["prd", "templates", "show", "standard"]) + assert result.exit_code == 0 + assert "standard" in result.output.lower() + # Should show sections + assert "section" in result.output.lower() or "executive" in result.output.lower() + + def test_show_template_not_found(self): + """Show command handles missing template.""" + result = runner.invoke(app, ["prd", "templates", "show", "nonexistent"]) + assert result.exit_code == 1 + assert "not found" in result.output.lower() or "error" in result.output.lower() + + def test_show_lean_template(self): + """Show command works for lean template.""" + result = runner.invoke(app, ["prd", "templates", "show", "lean"]) + assert result.exit_code == 0 + assert "lean" in result.output.lower() + + def test_show_enterprise_template(self): + """Show command works for enterprise template.""" + result = runner.invoke(app, ["prd", "templates", "show", "enterprise"]) + assert result.exit_code == 0 + assert "enterprise" in result.output.lower() + + +class TestPrdTemplatesExport: + """Tests for 'cf prd templates export' command.""" + + def test_export_template_to_file(self, tmp_path): + """Export command saves template to file.""" + output_file = tmp_path / "exported.yaml" + result = runner.invoke( + app, ["prd", "templates", "export", "standard", str(output_file)] + ) + assert result.exit_code == 0 + assert output_file.exists() + content = output_file.read_text() + assert "standard" in content + assert "sections" in content + + def test_export_template_not_found(self, tmp_path): + """Export command handles missing template.""" + output_file = tmp_path / "output.yaml" + result = runner.invoke( + app, ["prd", "templates", "export", "nonexistent", str(output_file)] + ) + assert result.exit_code == 1 + assert "not found" in result.output.lower() or "error" in result.output.lower() + + +class TestPrdTemplatesImport: + """Tests for 'cf prd templates import' command.""" + + def test_import_template_from_file(self, tmp_path): + """Import command loads template from file.""" + # Create a valid template file + template_file = tmp_path / "custom.yaml" + template_file.write_text(""" +id: custom-template +name: Custom Template +version: 1 +description: A custom PRD template +sections: + - id: intro + title: Introduction + source: problem + format_template: "## Introduction\\n\\n{{ problem }}" + required: true +""") + result = runner.invoke( + app, ["prd", "templates", "import", str(template_file)] + ) + assert result.exit_code == 0 + assert "imported" in result.output.lower() or "custom" in result.output.lower() + + def test_import_template_file_not_found(self, tmp_path): + """Import command handles missing file.""" + nonexistent = tmp_path / "missing.yaml" + result = runner.invoke( + app, ["prd", "templates", "import", str(nonexistent)] + ) + # Typer returns exit code 2 for file validation failures + assert result.exit_code != 0 + + +class TestPrdGenerateWithTemplate: + """Tests for 'cf prd generate --template' option.""" + + def test_generate_accepts_template_option(self, tmp_path): + """Generate command accepts --template option.""" + # Initialize a workspace first + workspace_path = tmp_path / "test-project" + workspace_path.mkdir() + (workspace_path / ".codeframe").mkdir() + + # This will fail due to no API key, but should at least parse the option + result = runner.invoke( + app, + [ + "prd", "generate", + "--template", "lean", + "-w", str(workspace_path), + ], + input="n\n", # Say no to prompts + ) + # The command may fail due to missing API key, but --template should be recognized + # Check that it doesn't fail with "unknown option" error + assert "unknown option" not in result.output.lower() + assert "--template" not in result.output.lower() or "template" in result.output.lower() + + def test_generate_invalid_template_shows_error(self, tmp_path): + """Generate command shows error for invalid template.""" + workspace_path = tmp_path / "test-project" + workspace_path.mkdir() + (workspace_path / ".codeframe").mkdir() + + result = runner.invoke( + app, + [ + "prd", "generate", + "--template", "nonexistent-template", + "-w", str(workspace_path), + ], + input="n\n", + ) + # Should indicate template not found + assert "not found" in result.output.lower() or "nonexistent" in result.output.lower() or result.exit_code != 0 + + +class TestPrdGenerateBuildPrompt: + """Tests for _build_prd_prompt integration.""" + + def test_build_prd_prompt_uses_template_sections(self): + """Verify _build_prd_prompt includes template section titles.""" + + # Create a mock session to access the method + class MockSession: + pass + + # Access the unbound method from the class + qa_history = "Q: What problem does this solve?\nA: Testing template integration." + + # Use a real template manager to build the prompt + from codeframe.planning.prd_templates import PrdTemplateManager + + manager = PrdTemplateManager() + lean_template = manager.get_template("lean") + + # Build prompt manually following the same logic + sections_spec = [] + for section in lean_template.sections: + required_note = " (required)" if section.required else " (optional)" + sections_spec.append(f"## {section.title}{required_note}") + + # Verify lean template has 3 sections + assert len(lean_template.sections) == 3 + assert "Problem" in sections_spec[0] + assert "Target Users" in sections_spec[1] + assert "MVP Features" in sections_spec[2] + + def test_generate_prd_stores_template_id_in_metadata(self): + """Verify template_id is stored in PRD metadata.""" + # This tests the signature change + from codeframe.core.prd_discovery import PrdDiscoverySession + import inspect + + sig = inspect.signature(PrdDiscoverySession.generate_prd) + params = list(sig.parameters.keys()) + + # Should have template_id parameter + assert "template_id" in params diff --git a/tests/planning/test_prd_templates.py b/tests/planning/test_prd_templates.py new file mode 100644 index 00000000..07ec1c8f --- /dev/null +++ b/tests/planning/test_prd_templates.py @@ -0,0 +1,727 @@ +"""Tests for PRD Template System. + +This module tests the PRD template system including: +- PrdTemplateSection and PrdTemplate data structures +- PrdTemplateManager operations (load, list, get, validate, render) +- Built-in template completeness +- Template function execution +- Integration with LeadAgent.generate_prd() +""" + +import pytest + +from codeframe.planning.prd_templates import ( + PrdTemplateSection, + PrdTemplate, + PrdTemplateManager, + BUILTIN_TEMPLATES, + load_template_from_file, +) + + +class TestPrdTemplateSection: + """Tests for PrdTemplateSection dataclass.""" + + def test_section_creation_with_required_fields(self): + """Section can be created with required fields.""" + section = PrdTemplateSection( + id="exec_summary", + title="Executive Summary", + source="problem", + format_template="## Executive Summary\n\n{{ problem }}", + ) + assert section.id == "exec_summary" + assert section.title == "Executive Summary" + assert section.source == "problem" + assert section.required is True # default + + def test_section_creation_with_optional_fields(self): + """Section can be created with optional required flag.""" + section = PrdTemplateSection( + id="risks", + title="Risk Assessment", + source="constraints", + format_template="## Risks\n\n{{ constraints }}", + required=False, + ) + assert section.required is False + + +class TestPrdTemplate: + """Tests for PrdTemplate dataclass.""" + + def test_template_creation(self): + """Template can be created with all fields.""" + sections = [ + PrdTemplateSection( + id="summary", + title="Summary", + source="problem", + format_template="## Summary\n\n{{ problem }}", + ) + ] + template = PrdTemplate( + id="test-template", + name="Test Template", + version=1, + description="A test template", + sections=sections, + ) + assert template.id == "test-template" + assert template.name == "Test Template" + assert template.version == 1 + assert len(template.sections) == 1 + + def test_template_section_ids(self): + """Template exposes section IDs for easy access.""" + sections = [ + PrdTemplateSection( + id="section1", title="S1", source="problem", format_template="" + ), + PrdTemplateSection( + id="section2", title="S2", source="users", format_template="" + ), + ] + template = PrdTemplate( + id="multi", name="Multi", version=1, description="", sections=sections + ) + assert template.section_ids == ["section1", "section2"] + + +class TestPrdTemplateManager: + """Tests for PrdTemplateManager.""" + + def test_manager_initialized_with_builtins(self): + """Manager initializes with built-in templates.""" + manager = PrdTemplateManager() + templates = manager.list_templates() + assert len(templates) >= 5 # standard, lean, enterprise, user-story-map, technical-spec + + def test_get_template_by_id(self): + """Can retrieve template by ID.""" + manager = PrdTemplateManager() + template = manager.get_template("standard") + assert template is not None + assert template.id == "standard" + assert template.name is not None + + def test_get_template_not_found(self): + """Returns None for unknown template ID.""" + manager = PrdTemplateManager() + template = manager.get_template("nonexistent-template") + assert template is None + + def test_list_templates_all(self): + """Can list all templates.""" + manager = PrdTemplateManager() + templates = manager.list_templates() + assert isinstance(templates, list) + assert all(isinstance(t, PrdTemplate) for t in templates) + + def test_validate_template_valid(self): + """Valid template passes validation.""" + manager = PrdTemplateManager() + template = manager.get_template("standard") + errors = manager.validate_template(template) + assert errors == [] + + def test_validate_template_missing_id(self): + """Template without ID fails validation.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="", # empty ID + name="Invalid", + version=1, + description="", + sections=[], + ) + errors = manager.validate_template(template) + assert len(errors) > 0 + assert any("id" in e.lower() for e in errors) + + def test_validate_template_no_sections(self): + """Template without sections fails validation.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="empty", + name="Empty", + version=1, + description="", + sections=[], + ) + errors = manager.validate_template(template) + assert len(errors) > 0 + assert any("section" in e.lower() for e in errors) + + def test_validate_template_invalid_jinja(self): + """Template with invalid Jinja2 syntax fails validation.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="bad-jinja", + name="Bad Jinja", + version=1, + description="", + sections=[ + PrdTemplateSection( + id="broken", + title="Broken", + source="problem", + format_template="{{ unclosed", # Invalid Jinja2 + ) + ], + ) + errors = manager.validate_template(template) + assert len(errors) > 0 + assert any("jinja" in e.lower() or "syntax" in e.lower() for e in errors) + + +class TestPrdTemplateRendering: + """Tests for template rendering.""" + + def test_render_simple_template(self): + """Can render a simple template with discovery data.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="simple", + name="Simple", + version=1, + description="", + sections=[ + PrdTemplateSection( + id="problem", + title="Problem", + source="problem", + format_template="## Problem\n\n{{ problem }}", + ) + ], + ) + + discovery_data = { + "problem": "Users need a better way to manage tasks", + "users": ["developers", "managers"], + "features": ["task creation", "assignment"], + "constraints": {"database": "PostgreSQL"}, + } + + rendered = manager.render_template(template, discovery_data) + assert "## Problem" in rendered + assert "Users need a better way to manage tasks" in rendered + + def test_render_with_bullet_list(self): + """Can render template using bullet_list function.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="with-list", + name="With List", + version=1, + description="", + sections=[ + PrdTemplateSection( + id="users", + title="Users", + source="users", + format_template="## Users\n\n{{ users | bullet_list }}", + ) + ], + ) + + discovery_data = { + "problem": "", + "users": ["developers", "managers", "admins"], + "features": [], + "constraints": {}, + } + + rendered = manager.render_template(template, discovery_data) + assert "- developers" in rendered + assert "- managers" in rendered + assert "- admins" in rendered + + def test_render_with_numbered_list(self): + """Can render template using numbered_list function.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="numbered", + name="Numbered", + version=1, + description="", + sections=[ + PrdTemplateSection( + id="features", + title="Features", + source="features", + format_template="## Features\n\n{{ features | numbered_list }}", + ) + ], + ) + + discovery_data = { + "problem": "", + "users": [], + "features": ["login", "dashboard", "reports"], + "constraints": {}, + } + + rendered = manager.render_template(template, discovery_data) + assert "1. login" in rendered + assert "2. dashboard" in rendered + assert "3. reports" in rendered + + def test_render_with_default_filter(self): + """Missing data uses default value.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="defaults", + name="Defaults", + version=1, + description="", + sections=[ + PrdTemplateSection( + id="tech", + title="Tech", + source="tech_stack", + format_template="## Tech\n\n{{ tech_stack | default('Not specified') }}", + ) + ], + ) + + discovery_data = { + "problem": "", + "users": [], + "features": [], + "constraints": {}, + # Note: tech_stack is missing + } + + rendered = manager.render_template(template, discovery_data) + assert "Not specified" in rendered + + def test_render_multiple_sections(self): + """Can render template with multiple sections.""" + manager = PrdTemplateManager() + template = manager.get_template("standard") + assert template is not None + + discovery_data = { + "problem": "Test problem statement", + "users": ["users", "admins"], + "features": ["feature1", "feature2"], + "constraints": {"database": "PostgreSQL"}, + "tech_stack": ["Python", "FastAPI"], + } + + rendered = manager.render_template(template, discovery_data) + # Should have multiple sections rendered + assert "Test problem statement" in rendered + assert len(rendered) > 100 # Should be substantial + + +class TestBuiltinTemplates: + """Tests for built-in templates.""" + + def test_standard_template_exists(self): + """Standard template is available.""" + manager = PrdTemplateManager() + template = manager.get_template("standard") + assert template is not None + assert template.id == "standard" + + def test_lean_template_exists(self): + """Lean template is available.""" + manager = PrdTemplateManager() + template = manager.get_template("lean") + assert template is not None + assert template.id == "lean" + + def test_enterprise_template_exists(self): + """Enterprise template is available.""" + manager = PrdTemplateManager() + template = manager.get_template("enterprise") + assert template is not None + assert template.id == "enterprise" + + def test_user_story_map_template_exists(self): + """User story map template is available.""" + manager = PrdTemplateManager() + template = manager.get_template("user-story-map") + assert template is not None + assert template.id == "user-story-map" + + def test_technical_spec_template_exists(self): + """Technical spec template is available.""" + manager = PrdTemplateManager() + template = manager.get_template("technical-spec") + assert template is not None + assert template.id == "technical-spec" + + def test_all_builtin_templates_valid(self): + """All built-in templates pass validation.""" + manager = PrdTemplateManager() + for template in BUILTIN_TEMPLATES: + errors = manager.validate_template(template) + assert errors == [], f"Template {template.id} failed validation: {errors}" + + def test_standard_template_has_required_sections(self): + """Standard template has expected sections.""" + manager = PrdTemplateManager() + template = manager.get_template("standard") + section_ids = template.section_ids + + # Expected sections from the issue spec + expected = [ + "executive_summary", + "problem_statement", + "user_personas", + "features", + "technical_architecture", + "success_metrics", + "timeline", + ] + for section_id in expected: + assert section_id in section_ids, f"Missing section: {section_id}" + + +class TestTemplateFunctions: + """Tests for template helper functions.""" + + def test_bullet_list_function(self): + """bullet_list creates markdown bullets.""" + from codeframe.planning.prd_template_functions import bullet_list + + result = bullet_list(["one", "two", "three"]) + assert result == "- one\n- two\n- three" + + def test_bullet_list_empty(self): + """bullet_list handles empty list.""" + from codeframe.planning.prd_template_functions import bullet_list + + result = bullet_list([]) + assert result == "" + + def test_numbered_list_function(self): + """numbered_list creates numbered items.""" + from codeframe.planning.prd_template_functions import numbered_list + + result = numbered_list(["first", "second"]) + assert result == "1. first\n2. second" + + def test_table_function(self): + """table creates markdown table.""" + from codeframe.planning.prd_template_functions import table + + items = [ + {"name": "Feature A", "priority": "P1"}, + {"name": "Feature B", "priority": "P2"}, + ] + result = table(items, ["name", "priority"]) + assert "| name | priority |" in result + assert "| Feature A | P1 |" in result + assert "| Feature B | P2 |" in result + + +class TestSecurityValidation: + """Tests for security-related validations.""" + + def test_load_rejects_non_yaml_files(self, tmp_path): + """load_template_from_file rejects non-YAML files.""" + txt_file = tmp_path / "template.txt" + txt_file.write_text("not a yaml file") + + with pytest.raises(ValueError, match="must be a YAML file"): + load_template_from_file(txt_file) + + def test_load_rejects_nonexistent_file(self, tmp_path): + """load_template_from_file raises FileNotFoundError for missing files.""" + missing = tmp_path / "missing.yaml" + + with pytest.raises(FileNotFoundError): + load_template_from_file(missing) + + def test_import_validates_template(self, tmp_path): + """import_template validates template before registering.""" + # Create invalid template (missing sections) + invalid_template = tmp_path / "invalid.yaml" + invalid_template.write_text(""" +id: invalid +name: Invalid Template +version: 1 +description: Missing sections +sections: [] +""") + + manager = PrdTemplateManager() + with pytest.raises(ValueError, match="Invalid template"): + manager.import_template(invalid_template) + + # Template should not be registered + assert manager.get_template("invalid") is None + + def test_load_rejects_empty_yaml_file(self, tmp_path): + """load_template_from_file rejects empty YAML files.""" + empty_file = tmp_path / "empty.yaml" + empty_file.write_text("") + + with pytest.raises(ValueError, match="empty or invalid"): + load_template_from_file(empty_file) + + def test_autoescape_prevents_html_injection(self): + """Jinja2 autoescape prevents HTML injection in rendered output.""" + manager = PrdTemplateManager() + template = PrdTemplate( + id="xss-test", + name="XSS Test", + version=1, + description="Test XSS prevention", + sections=[ + PrdTemplateSection( + id="problem", + title="Problem", + source="problem", + format_template="## Problem\n\n{{ problem }}", + ) + ], + ) + + # Discovery data with HTML injection attempt + discovery_data = { + "problem": "", + "users": [], + "features": [], + "constraints": {}, + } + + rendered = manager.render_template(template, discovery_data) + + # HTML should be escaped + assert "