diff --git a/src/cli/index.ts b/src/cli/index.ts index 780ba1d8..ab758355 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -41,7 +41,8 @@ program .command('init [path]') .description('Initialize OpenSpec in your project') .option('--tools ', toolsOptionDescription) - .action(async (targetPath = '.', options?: { tools?: string }) => { + .option('--templates', 'Generate change templates in openspec/templates/') + .action(async (targetPath = '.', options?: { tools?: string; templates?: boolean }) => { try { // Validate that the path is a valid directory const resolvedPath = path.resolve(targetPath); @@ -64,6 +65,7 @@ program const initCommand = new InitCommand({ tools: options?.tools, + templates: options?.templates, }); await initCommand.execute(targetPath); } catch (error) { diff --git a/src/core/init.ts b/src/core/init.ts index 05e9be42..8ddb02da 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -13,7 +13,7 @@ import { import chalk from 'chalk'; import ora from 'ora'; import { FileSystemUtils } from '../utils/file-system.js'; -import { TemplateManager, ProjectContext } from './templates/index.js'; +import { TemplateManager, ProjectContext, ChangeTemplateManager } from './templates/index.js'; import { ToolRegistry } from './configurators/registry.js'; import { SlashCommandRegistry } from './configurators/slash/registry.js'; import { @@ -371,15 +371,18 @@ const toolSelectionWizard = createPrompt( type InitCommandOptions = { prompt?: ToolSelectionPrompt; tools?: string; + templates?: boolean; }; export class InitCommand { private readonly prompt: ToolSelectionPrompt; private readonly toolsArg?: string; + private readonly generateTemplates?: boolean; constructor(options: InitCommandOptions = {}) { this.prompt = options.prompt ?? ((config) => toolSelectionWizard(config)); this.toolsArg = options.tools; + this.generateTemplates = options.templates; } async execute(targetPath: string): Promise { @@ -435,6 +438,9 @@ export class InitCommand { await this.ensureTemplateFiles(openspecPath, config); } + // Step 1.5: Optionally generate change templates + await this.maybeGenerateChangeTemplates(openspecPath); + // Step 2: Configure AI tools const toolSpinner = this.startSpinner('Configuring AI tools...'); const rootStubStatus = await this.configureAITools( @@ -718,6 +724,27 @@ export class InitCommand { } } + private async maybeGenerateChangeTemplates(openspecPath: string): Promise { + // Only generate if explicitly requested via --templates flag + if (!this.generateTemplates) { + return; + } + + // Skip if templates already exist + const hasTemplates = await ChangeTemplateManager.hasCustomTemplates(openspecPath); + if (hasTemplates) { + ora({ stream: process.stdout }).info( + PALETTE.midGray('ℹ Templates directory already exists, skipping template generation.') + ); + return; + } + + await ChangeTemplateManager.writeDefaultTemplates(openspecPath); + ora({ stream: process.stdout }).succeed( + PALETTE.white('Change templates generated in openspec/templates/') + ); + } + private async generateFiles( openspecPath: string, config: OpenSpecConfig diff --git a/src/core/templates/agents-template.ts b/src/core/templates/agents-template.ts index ad6dbdae..beed1aac 100644 --- a/src/core/templates/agents-template.ts +++ b/src/core/templates/agents-template.ts @@ -42,9 +42,10 @@ Skip proposal for: **Workflow** 1. Review \`openspec/project.md\`, \`openspec list\`, and \`openspec list --specs\` to understand current context. -2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, optional \`design.md\`, and spec deltas under \`openspec/changes//\`. -3. Draft spec deltas using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement. -4. Run \`openspec validate --strict\` and resolve any issues before sharing the proposal. +2. Check for custom templates: If \`openspec/templates/\` directory exists, use those templates when creating files (see Custom Templates section below). +3. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, optional \`design.md\`, and spec deltas under \`openspec/changes//\`. +4. Draft spec deltas using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement. +5. Run \`openspec validate --strict\` and resolve any issues before sharing the proposal. ### Stage 2: Implementing Changes Track these steps as TODOs and complete them one by one. @@ -154,6 +155,31 @@ New request? └─ Unclear? → Create proposal (safer) \`\`\` +### Custom Templates + +Projects can customize change file templates by creating \`openspec/templates/\` directory. To generate default templates, run: + +\`\`\`bash +openspec init --templates +\`\`\` + +This creates template files that you can edit: +- \`proposal.md.template\` +- \`tasks.md.template\` +- \`design.md.template\` +- \`spec.md.template\` + +When creating change files, the system will: + +- **Check for templates**: Before creating files, check if \`openspec/templates/\` exists +- **Use custom templates**: If template files exist, use them instead of defaults +- **Variable replacement**: Replace placeholders in templates: + - \`{{changeId}}\` → actual change-id (e.g., \`add-user-auth\`) + - \`{{date}}\` → current date in ISO format (e.g., \`2025-11-14\`) + - \`{{capability}}\` → capability name (only in spec.md, e.g., \`user-auth\`) +- **Fallback**: If template file doesn't exist, use the default structure shown below +- **Spec template validation**: Custom \`spec.md.template\` must include required tags (\`## ADDED Requirements\`, \`### Requirement:\`, \`#### Scenario:\`) or it will fall back to default + ### Proposal Structure 1. **Create directory:** \`changes/[change-id]/\` (kebab-case, verb-led, unique) diff --git a/src/core/templates/change-templates.ts b/src/core/templates/change-templates.ts new file mode 100644 index 00000000..035a02f7 --- /dev/null +++ b/src/core/templates/change-templates.ts @@ -0,0 +1,192 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +export type ChangeTemplateType = 'proposal' | 'tasks' | 'design' | 'spec'; + +export interface TemplateContext { + changeId?: string; + date?: string; + capability?: string; +} + +/** + * Default templates for change files + */ +const defaultTemplates: Record = { + proposal: `## Why +TODO: Explain why {{changeId}} is needed + +## What Changes +- TODO: List changes +- Created on {{date}} + +## Impact +- Affected specs: TODO +- Affected code: TODO`, + + tasks: `## 1. Implementation +- [ ] 1.1 TODO: First task for {{changeId}} +- [ ] 1.2 TODO: Second task`, + + design: `## Context +TODO: Background and constraints for {{changeId}} + +## Goals / Non-Goals +- Goals: TODO +- Non-Goals: TODO + +## Decisions +TODO: Technical decisions and rationale + +## Risks / Trade-offs +TODO: Risks and mitigation strategies`, + + spec: `## ADDED Requirements +### Requirement: [Replace with actual requirement name] +The system SHALL [describe the requirement]. + +#### Scenario: [Replace with scenario name] +- **WHEN** [condition] +- **THEN** [expected result]`, +}; + +/** + * Validates that a spec template contains required tags for archive to work correctly + */ +function validateSpecTemplate(content: string): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check for at least one delta section + const hasDeltaSection = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content); + if (!hasDeltaSection) { + errors.push('Missing required delta section (## ADDED Requirements, ## MODIFIED Requirements, etc.)'); + } + + // Check for Requirement header + const hasRequirement = /^###\s+Requirement:/m.test(content); + if (!hasRequirement) { + errors.push('Missing required ### Requirement: header'); + } + + // Check for Scenario header + const hasScenario = /^####\s+Scenario:/m.test(content); + if (!hasScenario) { + errors.push('Missing required #### Scenario: header'); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Replaces template variables with actual values + */ +function replaceVariables(content: string, context: TemplateContext): string { + let result = content; + + if (context.changeId !== undefined) { + result = result.replace(/\{\{changeId\}\}/g, context.changeId); + } + + if (context.date !== undefined) { + result = result.replace(/\{\{date\}\}/g, context.date); + } + + if (context.capability !== undefined) { + result = result.replace(/\{\{capability\}\}/g, context.capability); + } + + return result; +} + +/** + * Manages change file templates with support for custom templates and variable substitution + */ +export class ChangeTemplateManager { + /** + * Loads a template from file system or returns default template + * @param openspecDir Path to openspec directory + * @param type Template type + * @param context Variables to replace in template + * @returns Rendered template content + */ + static async loadTemplate( + openspecDir: string, + type: ChangeTemplateType, + context: TemplateContext = {} + ): Promise { + const templatePath = path.join(openspecDir, 'templates', `${type}.md.template`); + + let content: string; + let isCustom = false; + + try { + // Try to load custom template + content = await fs.readFile(templatePath, 'utf-8'); + isCustom = true; + } catch { + // File doesn't exist, use default + content = defaultTemplates[type]; + } + + // Validate spec template if it's custom + if (isCustom && type === 'spec') { + const validation = validateSpecTemplate(content); + if (!validation.valid) { + console.warn( + `Warning: Custom spec template at ${templatePath} is missing required tags:\n ${validation.errors.join('\n ')}\n Falling back to default template.` + ); + content = defaultTemplates[type]; + } + } + + // Replace variables + const rendered = replaceVariables(content, { + changeId: context.changeId || '', + date: context.date || new Date().toISOString().split('T')[0], + capability: context.capability || '', + }); + + return rendered; + } + + /** + * Gets the default template content for a given type + * @param type Template type + * @returns Default template content + */ + static getDefaultTemplate(type: ChangeTemplateType): string { + return defaultTemplates[type]; + } + + /** + * Checks if custom templates directory exists + * @param openspecDir Path to openspec directory + * @returns True if templates directory exists + */ + static async hasCustomTemplates(openspecDir: string): Promise { + const templatesDir = path.join(openspecDir, 'templates'); + try { + const stat = await fs.stat(templatesDir); + return stat.isDirectory(); + } catch { + return false; + } + } + + /** + * Writes default templates to the templates directory + * @param openspecDir Path to openspec directory + */ + static async writeDefaultTemplates(openspecDir: string): Promise { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + + for (const [type, content] of Object.entries(defaultTemplates)) { + const templatePath = path.join(templatesDir, `${type}.md.template`); + await fs.writeFile(templatePath, content, 'utf-8'); + } + } +} diff --git a/src/core/templates/index.ts b/src/core/templates/index.ts index 8dab4b5f..ffd12071 100644 --- a/src/core/templates/index.ts +++ b/src/core/templates/index.ts @@ -48,3 +48,4 @@ export class TemplateManager { export { ProjectContext } from './project-template.js'; export type { SlashCommandId } from './slash-command-templates.js'; +export { ChangeTemplateManager, type ChangeTemplateType, type TemplateContext } from './change-templates.js'; diff --git a/src/core/templates/slash-command-templates.ts b/src/core/templates/slash-command-templates.ts index be21328a..ae96777c 100644 --- a/src/core/templates/slash-command-templates.ts +++ b/src/core/templates/slash-command-templates.ts @@ -10,22 +10,41 @@ const proposalGuardrails = `${baseGuardrails}\n- Identify any vague or ambiguous const proposalSteps = `**Steps** 1. Review \`openspec/project.md\`, run \`openspec list\` and \`openspec list --specs\`, and inspect related code or docs (e.g., via \`rg\`/\`ls\`) to ground the proposal in current behaviour; note any gaps that require clarification. -2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, and \`design.md\` (when needed) under \`openspec/changes//\`. -3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. -4. Capture architectural reasoning in \`design.md\` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. -5. Draft spec deltas in \`changes//specs//spec.md\` (one folder per capability) using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement and cross-reference related capabilities when relevant. -6. Draft \`tasks.md\` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. -7. Validate with \`openspec validate --strict\` and resolve every issue before sharing the proposal.`; +2. Choose a unique verb-led \`change-id\` and create the change directory \`openspec/changes//\`. +3. Create \`proposal.md\` in \`openspec/changes//\`: + - **CRITICAL**: Check if \`openspec/templates/proposal.md.template\` exists + - If exists: Read the template file and replace variables (\`{{changeId}}\` → actual change-id, \`{{date}}\` → current date in YYYY-MM-DD format) + - If not exists: Use the default structure from \`openspec/AGENTS.md\` (see "Proposal Structure" section) +4. Create \`tasks.md\` in \`openspec/changes//\`: + - **CRITICAL**: Check if \`openspec/templates/tasks.md.template\` exists + - If exists: Read the template file and replace variables (\`{{changeId}}\` → actual change-id, \`{{date}}\` → current date in YYYY-MM-DD format) + - If not exists: Use the default structure from \`openspec/AGENTS.md\` (see "Proposal Structure" section) +5. Create \`design.md\` in \`openspec/changes//\` (only when needed—see criteria in \`openspec/AGENTS.md\`): + - **CRITICAL**: Check if \`openspec/templates/design.md.template\` exists + - If exists: Read the template file and replace variables (\`{{changeId}}\` → actual change-id, \`{{date}}\` → current date in YYYY-MM-DD format) + - If not exists: Use the default structure from \`openspec/AGENTS.md\` (see "Proposal Structure" section) +6. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +7. Create spec deltas in \`openspec/changes//specs//spec.md\` (one folder per capability): + - **CRITICAL**: Check if \`openspec/templates/spec.md.template\` exists + - If exists: Read the template file and replace variables (\`{{changeId}}\` → actual change-id, \`{{date}}\` → current date in YYYY-MM-DD format, \`{{capability}}\` → capability name) + - If not exists: Use \`## ADDED|MODIFIED|REMOVED Requirements\` format with at least one \`#### Scenario:\` per requirement + - Cross-reference related capabilities when relevant +8. Validate with \`openspec validate --strict\` and resolve every issue before sharing the proposal.`; + const proposalReferences = `**Reference** +- **Template Processing**: For each file type (proposal, tasks, design, spec), always check \`openspec/templates/.md.template\` first. If the template exists, read it and replace all variables before writing the file. Template variables: + - \`{{changeId}}\` → the actual change-id (e.g., \`add-user-auth\`) + - \`{{date}}\` → current date in YYYY-MM-DD format (e.g., \`2025-11-14\`) + - \`{{capability}}\` → capability name (only used in spec.md, e.g., \`user-auth\`) - Use \`openspec show --json --deltas-only\` or \`openspec show --type spec\` to inspect details when validation fails. - Search existing requirements with \`rg -n "Requirement:|Scenario:" openspec/specs\` before writing new ones. - Explore the codebase with \`rg \`, \`ls\`, or direct file reads so proposals align with current implementation realities.`; const applySteps = `**Steps** Track these steps as TODOs and complete them one by one. -1. Read \`changes//proposal.md\`, \`design.md\` (if present), and \`tasks.md\` to confirm scope and acceptance criteria. +1. Read \`openspec/changes//proposal.md\`, \`design.md\` (if present), and \`tasks.md\` to confirm scope and acceptance criteria. 2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. 3. Confirm completion before updating statuses—make sure every item in \`tasks.md\` is finished. 4. Update the checklist after all work is done so each task is marked \`- [x]\` and reflects reality. @@ -42,7 +61,7 @@ const archiveSteps = `**Steps** - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. 2. Validate the change ID by running \`openspec list\` (or \`openspec show \`) and stop if the change is missing, already archived, or otherwise not ready to archive. 3. Run \`openspec archive --yes\` so the CLI moves the change and applies spec updates without prompts (use \`--skip-specs\` only for tooling-only work). -4. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`. +4. Review the command output to confirm the target specs were updated and the change landed in \`openspec/changes/archive/\`. 5. Validate with \`openspec validate --strict\` and inspect with \`openspec show \` if anything looks off.`; const archiveReferences = `**Reference** diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 1ebe4471..2b39582a 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1609,6 +1609,106 @@ describe('InitCommand', () => { /Insufficient permissions/ ); }); + + describe('change templates generation', () => { + it('should not generate templates by default', async () => { + queueSelections('claude', DONE); + + await initCommand.execute(testDir); + + const templatesDir = path.join(testDir, 'openspec', 'templates'); + const exists = await directoryExists(templatesDir); + expect(exists).toBe(false); + }); + + it('should generate templates when --templates flag is provided', async () => { + queueSelections('claude', DONE); + const templatesCommand = new InitCommand({ + prompt: mockPrompt, + templates: true, + }); + + await templatesCommand.execute(testDir); + + const templatesDir = path.join(testDir, 'openspec', 'templates'); + expect(await directoryExists(templatesDir)).toBe(true); + + const templateFiles = [ + 'proposal.md.template', + 'tasks.md.template', + 'design.md.template', + 'spec.md.template', + ]; + + for (const file of templateFiles) { + const filePath = path.join(templatesDir, file); + expect(await fileExists(filePath)).toBe(true); + const content = await fs.readFile(filePath, 'utf-8'); + expect(content).toBeTruthy(); + } + }); + + it('should skip template generation if templates already exist', async () => { + queueSelections('claude', DONE); + const templatesDir = path.join(testDir, 'openspec', 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + await fs.writeFile( + path.join(templatesDir, 'proposal.md.template'), + 'existing template' + ); + + const templatesCommand = new InitCommand({ + prompt: mockPrompt, + templates: true, + }); + + await templatesCommand.execute(testDir); + + // Should skip generation and show info message + const content = await fs.readFile( + path.join(templatesDir, 'proposal.md.template'), + 'utf-8' + ); + expect(content).toBe('existing template'); + }); + + it('should not generate templates when flag is not provided', async () => { + const nonTemplatesCommand = new InitCommand({ + prompt: mockPrompt, + tools: 'claude', + }); + queueSelections('claude', DONE); + + await nonTemplatesCommand.execute(testDir); + + const templatesDir = path.join(testDir, 'openspec', 'templates'); + const exists = await directoryExists(templatesDir); + expect(exists).toBe(false); + }); + + it('should generate valid spec template with required tags', async () => { + queueSelections('claude', DONE); + const templatesCommand = new InitCommand({ + prompt: mockPrompt, + templates: true, + }); + + await templatesCommand.execute(testDir); + + const specTemplatePath = path.join( + testDir, + 'openspec', + 'templates', + 'spec.md.template' + ); + const content = await fs.readFile(specTemplatePath, 'utf-8'); + + // Verify required tags for archive to work + expect(content).toMatch(/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m); + expect(content).toContain('### Requirement:'); + expect(content).toContain('#### Scenario:'); + }); + }); }); }); diff --git a/test/core/templates/change-templates.test.ts b/test/core/templates/change-templates.test.ts new file mode 100644 index 00000000..db0e1d90 --- /dev/null +++ b/test/core/templates/change-templates.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { ChangeTemplateManager, type ChangeTemplateType } from '../../../src/core/templates/change-templates.js'; + +describe('ChangeTemplateManager', () => { + let testDir: string; + let openspecDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-templates-test-${randomUUID()}`); + openspecDir = path.join(testDir, 'openspec'); + await fs.mkdir(openspecDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + describe('loadTemplate', () => { + it('should load default template when custom template does not exist', async () => { + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'proposal'); + + expect(content).toContain('## Why'); + expect(content).toContain('## What Changes'); + expect(content).toContain('## Impact'); + }); + + it('should load custom template when it exists', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const customTemplate = '## Custom Proposal\nThis is a custom template.'; + await fs.writeFile(path.join(templatesDir, 'proposal.md.template'), customTemplate); + + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'proposal'); + + expect(content).toBe(customTemplate); + }); + + it('should replace {{changeId}} variable', async () => { + const content = await ChangeTemplateManager.loadTemplate( + openspecDir, + 'proposal', + { changeId: 'add-user-auth' } + ); + + expect(content).toContain('add-user-auth'); + expect(content).not.toContain('{{changeId}}'); + }); + + it('should replace {{date}} variable', async () => { + const testDate = '2025-11-14'; + const content = await ChangeTemplateManager.loadTemplate( + openspecDir, + 'proposal', + { date: testDate } + ); + + expect(content).toContain(testDate); + expect(content).not.toContain('{{date}}'); + }); + + it('should replace {{capability}} variable in custom spec template', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const customTemplate = `## ADDED Requirements +### Requirement: {{capability}} Feature +The system SHALL provide {{capability}}. + +#### Scenario: {{capability}} Scenario +- **WHEN** condition +- **THEN** result`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), customTemplate); + + const content = await ChangeTemplateManager.loadTemplate( + openspecDir, + 'spec', + { capability: 'user-auth' } + ); + + expect(content).toContain('user-auth'); + expect(content).not.toContain('{{capability}}'); + }); + + it('should replace all variables in valid spec template', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const customTemplate = `## ADDED Requirements +### Requirement: {{capability}} Feature +Change: {{changeId}} +Date: {{date}} +Capability: {{capability}} + +#### Scenario: {{capability}} Scenario +- **WHEN** condition +- **THEN** result`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), customTemplate); + + const content = await ChangeTemplateManager.loadTemplate( + openspecDir, + 'spec', + { changeId: 'add-feature', date: '2025-11-14', capability: 'auth' } + ); + + expect(content).toContain('Change: add-feature'); + expect(content).toContain('Date: 2025-11-14'); + expect(content).toContain('Capability: auth'); + expect(content).not.toContain('{{changeId}}'); + expect(content).not.toContain('{{date}}'); + expect(content).not.toContain('{{capability}}'); + }); + + it('should use current date when date is not provided', async () => { + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'proposal'); + const today = new Date().toISOString().split('T')[0]; + + expect(content).toContain(today); + }); + + it('should validate spec template and fallback to default if invalid', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + // Invalid template: missing required tags + const invalidTemplate = 'This template is missing required tags.'; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), invalidTemplate); + + // Mock console.warn to capture the warning + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + // Should fallback to default template + expect(content).toContain('## ADDED Requirements'); + expect(content).toContain('### Requirement:'); + expect(content).toContain('#### Scenario:'); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should accept valid spec template with ADDED Requirements', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const validTemplate = `## ADDED Requirements +### Requirement: Test Requirement +The system SHALL test. + +#### Scenario: Test Scenario +- **WHEN** test condition +- **THEN** test result`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), validTemplate); + + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + expect(content).toBe(validTemplate); + }); + + it('should accept valid spec template with MODIFIED Requirements', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const validTemplate = `## MODIFIED Requirements +### Requirement: Modified Requirement +The system SHALL be modified. + +#### Scenario: Modified Scenario +- **WHEN** condition +- **THEN** result`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), validTemplate); + + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + expect(content).toBe(validTemplate); + }); + + it('should load all template types', async () => { + const types: ChangeTemplateType[] = ['proposal', 'tasks', 'design', 'spec']; + + for (const type of types) { + const content = await ChangeTemplateManager.loadTemplate(openspecDir, type); + expect(content).toBeTruthy(); + expect(typeof content).toBe('string'); + } + }); + }); + + describe('loadTemplate with default templates', () => { + it('should render template with variables synchronously', async () => { + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'proposal', { + changeId: 'add-feature', + date: '2025-11-14', + }); + + expect(content).toContain('add-feature'); + expect(content).toContain('2025-11-14'); + expect(content).not.toContain('{{changeId}}'); + expect(content).not.toContain('{{date}}'); + }); + + it('should use current date when date is not provided', async () => { + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'proposal'); + const today = new Date().toISOString().split('T')[0]; + + expect(content).toContain(today); + }); + + it('should handle empty context', async () => { + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'tasks'); + + expect(content).toBeTruthy(); + expect(typeof content).toBe('string'); + }); + }); + + describe('getDefaultTemplate', () => { + it('should return default template for each type', () => { + const types: ChangeTemplateType[] = ['proposal', 'tasks', 'design', 'spec']; + + for (const type of types) { + const template = ChangeTemplateManager.getDefaultTemplate(type); + expect(template).toBeTruthy(); + expect(typeof template).toBe('string'); + } + }); + + it('should return proposal template with correct structure', () => { + const template = ChangeTemplateManager.getDefaultTemplate('proposal'); + + expect(template).toContain('## Why'); + expect(template).toContain('## What Changes'); + expect(template).toContain('## Impact'); + }); + + it('should return tasks template with correct structure', () => { + const template = ChangeTemplateManager.getDefaultTemplate('tasks'); + + expect(template).toContain('## 1. Implementation'); + expect(template).toContain('- [ ]'); + }); + + it('should return design template with correct structure', () => { + const template = ChangeTemplateManager.getDefaultTemplate('design'); + + expect(template).toContain('## Context'); + expect(template).toContain('## Goals / Non-Goals'); + expect(template).toContain('## Decisions'); + expect(template).toContain('## Risks / Trade-offs'); + }); + + it('should return spec template with required tags', () => { + const template = ChangeTemplateManager.getDefaultTemplate('spec'); + + expect(template).toContain('## ADDED Requirements'); + expect(template).toContain('### Requirement:'); + expect(template).toContain('#### Scenario:'); + }); + }); + + describe('hasCustomTemplates', () => { + it('should return false when templates directory does not exist', async () => { + const hasTemplates = await ChangeTemplateManager.hasCustomTemplates(openspecDir); + + expect(hasTemplates).toBe(false); + }); + + it('should return true when templates directory exists', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + + const hasTemplates = await ChangeTemplateManager.hasCustomTemplates(openspecDir); + + expect(hasTemplates).toBe(true); + }); + + it('should return false when path is a file, not a directory', async () => { + const filePath = path.join(openspecDir, 'templates'); + await fs.writeFile(filePath, 'not a directory'); + + const hasTemplates = await ChangeTemplateManager.hasCustomTemplates(openspecDir); + + expect(hasTemplates).toBe(false); + }); + }); + + describe('writeDefaultTemplates', () => { + it('should create templates directory and write all template files', async () => { + await ChangeTemplateManager.writeDefaultTemplates(openspecDir); + + const templatesDir = path.join(openspecDir, 'templates'); + expect(await fs.stat(templatesDir)).toBeDefined(); + + const types: ChangeTemplateType[] = ['proposal', 'tasks', 'design', 'spec']; + for (const type of types) { + const templatePath = path.join(templatesDir, `${type}.md.template`); + const exists = await fs.access(templatePath).then(() => true).catch(() => false); + expect(exists).toBe(true); + + const content = await fs.readFile(templatePath, 'utf-8'); + expect(content).toBeTruthy(); + } + }); + + it('should write all templates even when some already exist', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const customContent = 'Custom template content'; + await fs.writeFile(path.join(templatesDir, 'proposal.md.template'), customContent); + + await ChangeTemplateManager.writeDefaultTemplates(openspecDir); + + // writeDefaultTemplates uses fs.writeFile which overwrites existing files + // This test verifies that the method writes all template types correctly + const content = await fs.readFile(path.join(templatesDir, 'proposal.md.template'), 'utf-8'); + expect(content).toBeTruthy(); + // Verify it was overwritten with default content (not the custom content) + expect(content).toContain('## Why'); + expect(content).toContain('## What Changes'); + expect(content).not.toBe(customContent); + }); + + it('should create nested templates directory if needed', async () => { + const nestedOpenspecDir = path.join(testDir, 'nested', 'openspec'); + await ChangeTemplateManager.writeDefaultTemplates(nestedOpenspecDir); + + const templatesDir = path.join(nestedOpenspecDir, 'templates'); + const stats = await fs.stat(templatesDir); + expect(stats.isDirectory()).toBe(true); + }); + }); + + describe('spec template validation', () => { + it('should accept template with REMOVED Requirements', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const validTemplate = `## REMOVED Requirements +### Requirement: Old Requirement +**Reason**: Deprecated + +#### Scenario: Removal Scenario +- **WHEN** condition +- **THEN** removed`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), validTemplate); + + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + expect(content).toBe(validTemplate); + }); + + it('should accept template with RENAMED Requirements', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const validTemplate = `## RENAMED Requirements +### Requirement: Old Name +FROM: Old Name +TO: New Name + +#### Scenario: Rename Scenario +- **WHEN** condition +- **THEN** renamed`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), validTemplate); + + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + expect(content).toBe(validTemplate); + }); + + it('should reject template missing delta section', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const invalidTemplate = `### Requirement: Test +Some content here.`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), invalidTemplate); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + expect(content).toContain('## ADDED Requirements'); // Should fallback to default + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should reject template missing Requirement header', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const invalidTemplate = `## ADDED Requirements +Some content without requirement header.`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), invalidTemplate); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + expect(content).toContain('### Requirement:'); // Should fallback to default + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should reject template missing Scenario header', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const invalidTemplate = `## ADDED Requirements +### Requirement: Test Requirement +Some content without scenario.`; + await fs.writeFile(path.join(templatesDir, 'spec.md.template'), invalidTemplate); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const content = await ChangeTemplateManager.loadTemplate(openspecDir, 'spec'); + + expect(content).toContain('#### Scenario:'); // Should fallback to default + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + }); + + describe('edge cases', () => { + it('should handle multiple variable occurrences', async () => { + const templatesDir = path.join(openspecDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + const template = '{{changeId}} and {{changeId}} again'; + await fs.writeFile(path.join(templatesDir, 'proposal.md.template'), template); + + const content = await ChangeTemplateManager.loadTemplate( + openspecDir, + 'proposal', + { changeId: 'test' } + ); + + expect(content).toBe('test and test again'); + }); + + it('should handle empty variables', async () => { + const content = await ChangeTemplateManager.loadTemplate( + openspecDir, + 'proposal', + { changeId: '', date: '', capability: '' } + ); + + expect(content).not.toContain('{{changeId}}'); + expect(content).not.toContain('{{date}}'); + }); + + it('should handle special characters in variables', async () => { + const content = await ChangeTemplateManager.loadTemplate( + openspecDir, + 'proposal', + { changeId: 'add-user-auth-v2', date: '2025-11-14', capability: 'user-auth/v2' } + ); + + expect(content).toContain('add-user-auth-v2'); + expect(content).toContain('2025-11-14'); + }); + }); +});