Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale ac659f7993 feat: add CodeRabbit AI assistant support
Add CodeRabbit configuration to enable AI-powered code reviews with customized review settings, path-specific instructions, and chat configuration.
2025-10-21 13:18:31 +11:00
scala63andningchen ece61a6d68 feat: add Cline support (#213)
Co-authored-by: ningchen <n.ning.c.chen@oracle.com>
2025-10-21 12:08:21 +11:00
Tabish Bidiwale ecddffc22e fix: improve delta spec validation with case-insensitive headers and empty section detection (#191)
This commit enhances the validation logic for delta specs:
- Delta section headers are now parsed case-insensitively (e.g., "Added Requirements" and "ADDED Requirements" both work)
- Empty delta sections now produce clear error messages guiding users to add requirement entries
- Specs with no delta headers at all now receive specific error messages
- Added test coverage for case-insensitive delta header parsing
2025-10-19 23:56:53 +11:00
HariKrishnanandClaude 822464ec44 chore(dev): add VS Code dev container configuration (#209)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-19 21:50:50 +11:00
Gianluca BoianoandCrush 67ab683105 feat: add Crush AI assistant support (#206)
Add comprehensive OpenSpec integration for Crush AI assistant including:
- CrushSlashCommandConfigurator for proposal, apply, and archive commands
- Integration with slash command registry and CLI tools
- Generates .crush/commands/openspec/ with proper frontmatter and workflows
- Available via `openspec init --tools crush`


💘 Generated with Crush

Co-authored-by: Crush <crush@charm.land>
2025-10-19 11:55:36 +11:00
Vimpas f82e243551 feat: add Auggie (Augment CLI) support to configuration and documenta… (#196)
* feat: add Auggie (Augment CLI) support to configuration and documentation

- Added Auggie (Augment CLI) to the AI tools configuration in `config.ts`.
- Updated the slash command registry to include Auggie's configurator.
- Documented Auggie's commands in the README.md for better visibility.

This enhances the integration of Auggie within the existing toolset.

* test: add tests for Auggie command file creation and updates

- Implemented tests to verify the creation of Auggie slash command files with appropriate templates.
- Added checks to ensure existing Auggie command files are refreshed correctly during updates.
- Confirmed that missing command files are not created during the update process.

These changes enhance the test coverage for Auggie's integration and ensure proper functionality of command file management.
2025-10-17 14:01:28 +11:00
Tabish Bidiwale 88b260d51f fix: honor --no-validate and ignore metadata during archive validation (#190)
* fix: skip metadata when validating requirement SHALL/MUST keywords

Fixes validation incorrectly checking metadata lines instead of requirement text.

The extractRequirementText() function was returning the first non-empty line
after the requirement header, which was often metadata like **ID**: REQ-001
instead of the actual requirement statement.

Changes:
- Updated extractRequirementText() to skip lines matching **Key**: Value pattern
- Skip blank lines between header and requirement text
- Return first substantive text line for SHALL/MUST validation

Added comprehensive tests for:
- Requirements with metadata before SHALL/MUST text
- Requirements with SHALL in text but not header
- Requirements correctly failing without SHALL/MUST
- Requirements without metadata fields

All 20 validation tests pass.

Fixes #159

* Respect --no-validate flag while archiving
2025-10-16 17:34:33 +11:00
28 changed files with 1137 additions and 24 deletions
+11
View File
@@ -0,0 +1,11 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
# Minimal configuration for getting started
language: "en-US"
reviews:
profile: "chill"
high_level_summary: true
auto_review:
enabled: true
drafts: false
base_branches:
- ".*"
+92
View File
@@ -0,0 +1,92 @@
# Dev Container Setup
This directory contains the VS Code dev container configuration for OpenSpec development.
## What's Included
- **Node.js 20 LTS** (>=20.19.0) - TypeScript/JavaScript runtime
- **pnpm** - Fast, disk space efficient package manager
- **Git + GitHub CLI** - Version control tools
- **VS Code Extensions**:
- ESLint & Prettier for code quality
- Vitest Explorer for running tests
- GitLens for enhanced git integration
- Error Lens for inline error highlighting
- Code Spell Checker
- Path IntelliSense
## How to Use
### First Time Setup
1. **Install Prerequisites** (on your local machine):
- [VS Code](https://code.visualstudio.com/)
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
- [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
2. **Open in Container**:
- Open this project in VS Code
- You'll see a notification: "Folder contains a Dev Container configuration file"
- Click "Reopen in Container"
OR
- Open Command Palette (`Cmd/Ctrl+Shift+P`)
- Type "Dev Containers: Reopen in Container"
- Press Enter
3. **Wait for Setup**:
- The container will build (first time takes a few minutes)
- `pnpm install` runs automatically via `postCreateCommand`
- All extensions install automatically
### Daily Development
Once set up, the container preserves your development environment:
```bash
# Run development build
pnpm run dev
# Run CLI in development
pnpm run dev:cli
# Run tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Build the project
pnpm run build
```
### SSH Keys
Your SSH keys are mounted read-only from `~/.ssh`, so git operations work seamlessly with GitHub/GitLab.
### Rebuilding the Container
If you modify `.devcontainer/devcontainer.json`:
- Command Palette → "Dev Containers: Rebuild Container"
## Benefits
- No need to install Node.js or pnpm on your local machine
- Consistent development environment across team members
- Isolated from other Node.js projects on your machine
- All dependencies and tools containerized
- Easy onboarding for new developers
## Troubleshooting
**Container won't build:**
- Ensure Docker Desktop is running
- Check Docker has enough memory allocated (recommend 4GB+)
**Extensions not appearing:**
- Rebuild the container: "Dev Containers: Rebuild Container"
**Permission issues:**
- The container runs as the `node` user (non-root)
- Files created in the container are owned by this user
+68
View File
@@ -0,0 +1,68 @@
{
"name": "OpenSpec Development",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm",
// Additional tools and features
"features": {
"ghcr.io/devcontainers/features/git:1": {
"version": "latest",
"ppa": true
},
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "latest"
}
},
// Configure tool-specific properties
"customizations": {
"vscode": {
// Set default container specific settings
"settings": {
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"files.eol": "\n",
"terminal.integrated.defaultProfile.linux": "bash"
},
// Add extensions you want installed when the container is created
"extensions": [
// TypeScript/JavaScript essentials
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
// Testing
"vitest.explorer",
// Git
"eamodio.gitlens",
// Utilities
"streetsidesoftware.code-spell-checker",
"usernamehw.errorlens",
"christian-kohler.path-intellisense"
]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created
"postCreateCommand": "corepack enable && corepack prepare pnpm@latest --activate && pnpm install",
// Configure mounts to preserve SSH keys for git operations
"mounts": [
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,readonly,type=bind,consistency=cached"
],
// Set the default user to 'node' (non-root user)
"remoteUser": "node",
// Ensure git is properly configured
"initializeCommand": "echo 'Initializing dev container...'"
}
+3
View File
@@ -147,3 +147,6 @@ docs/
.claude/
CLAUDE.md
.DS_Store
# Pnpm
.pnpm-store/
+2
View File
@@ -92,6 +92,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|------|----------|
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) |
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
@@ -99,6 +100,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.
@@ -0,0 +1,15 @@
## Why
Add support for Cline (VS Code extension) in OpenSpec to enable developers to use Cline's AI-powered coding capabilities for spec-driven development workflows.
## What Changes
- Add Cline slash command configurator for proposal, apply, and archive operations
- Add Cline root CLINE.md configurator for project-level instructions
- Add Cline template exports
- Update tool and slash command registries to include Cline
- Add comprehensive test coverage
- **BREAKING**: None - this is additive functionality
## Impact
- Affected specs: cli-init (new tool option)
- Affected code: src/core/configurators/slash/cline.ts, src/core/configurators/cline.ts, registry files
- New files: .clinerules/openspec-*.md, CLINE.md
@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Cline Tool Support
The system SHALL provide Cline (VS Code extension) as a supported tool option during OpenSpec initialization.
#### Scenario: Initialize project with Cline support
- **WHEN** user runs `openspec init --tools cline`
- **THEN** Cline-specific rule files are configured in `.clinerules/`
- **AND** CLINE.md root file includes OpenSpec workflow instructions
- **AND** Cline is registered as available configurator
#### Scenario: Cline proposal rule generation
- **WHEN** Cline rules are configured
- **THEN** `.clinerules/openspec-proposal.md` contains proposal workflow with guardrails
- **AND** Includes Cline-specific Markdown heading frontmatter
- **AND** Follows established slash command template pattern
#### Scenario: Cline apply and archive rules
- **WHEN** Cline rules are configured
- **THEN** `.clinerules/openspec-apply.md` contains implementation workflow
- **AND** `.clinerules/openspec-archive.md` contains archiving workflow
- **AND** Both commands include appropriate headers and references
#### Scenario: Cline root instructions
- **WHEN** Cline is selected during initialization
- **THEN** CLINE.md is created at project root
- **AND** Contains OpenSpec markers for managed content
- **AND** References `@/openspec/AGENTS.md` for workflow instructions
@@ -0,0 +1,19 @@
## 1. Implementation
- [x] 1.1 Create ClineSlashCommandConfigurator class in src/core/configurators/slash/cline.ts
- [x] 1.2 Create ClineConfigurator class in src/core/configurators/cline.ts
- [x] 1.3 Create cline-template.ts for template exports
- [x] 1.4 Define file paths for Cline rules (.clinerules/)
- [x] 1.5 Create Cline-specific frontmatter (Markdown heading format)
- [x] 1.6 Register Cline in slash/registry.ts
- [x] 1.7 Register Cline in configurators/registry.ts
- [x] 1.8 Add Cline to AI_TOOLS in config.ts
- [x] 1.9 Add getClineTemplate() to templates/index.ts
- [x] 1.10 Update README with Cline documentation
## 2. Testing
- [x] 2.1 Add init tests for CLINE.md creation and updates
- [x] 2.2 Add init tests for .clinerules/ file creation
- [x] 2.3 Add update tests for CLINE.md updates
- [x] 2.4 Add update tests for .clinerules/ file refreshes
- [x] 2.5 Test integration with openspec init --tools cline
- [x] 2.6 Verify all 225 tests pass
@@ -0,0 +1,13 @@
## Why
Add support for Crush AI assistant in OpenSpec to enable developers to use Crush's enhanced capabilities for spec-driven development workflows.
## What Changes
- Add Crush slash command configurator for proposal, apply, and archive operations
- Add Crush-specific AGENTS.md configuration template
- Update tool registry to include Crush configurator
- **BREAKING**: None - this is additive functionality
## Impact
- Affected specs: cli-init (new tool option)
- Affected code: src/core/configurators/slash/crush.ts, registry.ts
- New files: .crush/commands/openspec/ (proposal.md, apply.md, archive.md)
@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: Crush Tool Support
The system SHALL provide Crush AI assistant as a supported tool option during OpenSpec initialization.
#### Scenario: Initialize project with Crush support
- **WHEN** user runs `openspec init --tool crush`
- **THEN** Crush-specific slash commands are configured in `.crush/commands/openspec/`
- **AND** Crush AGENTS.md includes OpenSpec workflow instructions
- **AND** Crush is registered as available configurator
#### Scenario: Crush proposal command generation
- **WHEN** Crush slash commands are configured
- **THEN** `.crush/commands/openspec/proposal.md` contains proposal workflow with guardrails
- **AND** Includes Crush-specific frontmatter with OpenSpec category and tags
- **AND** Follows established slash command template pattern
#### Scenario: Crush apply and archive commands
- **WHEN** Crush slash commands are configured
- **THEN** `.crush/commands/openspec/apply.md` contains implementation workflow
- **AND** `.crush/commands/openspec/archive.md` contains archiving workflow
- **AND** Both commands include appropriate frontmatter and references
@@ -0,0 +1,7 @@
## 1. Implementation
- [x] 1.1 Create CrushSlashCommandConfigurator class in src/core/configurators/slash/crush.ts
- [x] 1.2 Define file paths for Crush commands (.crush/commands/openspec/)
- [x] 1.3 Create Crush-specific frontmatter for proposal, apply, archive commands
- [x] 1.4 Register Crush configurator in slash/registry.ts
- [x] 1.5 Add Crush to available tools in cli-init command
- [x] 1.6 Test integration with openspec init --tool crush
+1 -1
View File
@@ -187,7 +187,7 @@ program
.option('-y, --yes', 'Skip confirmation prompts')
.option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
.option('--no-validate', 'Skip validation (not recommended, requires confirmation)')
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean }) => {
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean }) => {
try {
const archiveCommand = new ArchiveCommand();
await archiveCommand.execute(changeName, options);
+9 -4
View File
@@ -19,7 +19,10 @@ interface SpecUpdate {
}
export class ArchiveCommand {
async execute(changeName?: string, options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean } = {}): Promise<void> {
async execute(
changeName?: string,
options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean } = {}
): Promise<void> {
const targetPath = '.';
const changesDir = path.join(targetPath, 'openspec', 'changes');
const archiveDir = path.join(changesDir, 'archive');
@@ -54,8 +57,10 @@ export class ArchiveCommand {
throw new Error(`Change '${changeName}' not found.`);
}
const skipValidation = options.validate === false || options.noValidate === true;
// Validate specs and change before archiving
if (!options.noValidate) {
if (!skipValidation) {
const validator = new Validator();
let hasValidationErrors = false;
@@ -201,7 +206,7 @@ export class ArchiveCommand {
let totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
for (const p of prepared) {
const specName = path.basename(path.dirname(p.update.target));
if (!options.noValidate) {
if (!skipValidation) {
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
if (!report.valid) {
console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
@@ -598,4 +603,4 @@ export class ArchiveCommand {
// Returns date in YYYY-MM-DD format
return new Date().toISOString().split('T')[0];
}
}
}
+3
View File
@@ -17,7 +17,10 @@ export interface AIToolOption {
}
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class ClineConfigurator implements ToolConfigurator {
name = 'Cline';
configFileName = 'CLINE.md';
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getClineTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+3
View File
@@ -1,5 +1,6 @@
import { ToolConfigurator } from './base.js';
import { ClaudeConfigurator } from './claude.js';
import { ClineConfigurator } from './cline.js';
import { AgentsStandardConfigurator } from './agents.js';
export class ToolRegistry {
@@ -7,9 +8,11 @@ export class ToolRegistry {
static {
const claudeConfigurator = new ClaudeConfigurator();
const clineConfigurator = new ClineConfigurator();
const agentsConfigurator = new AgentsStandardConfigurator();
// Register with the ID that matches the checkbox value
this.tools.set('claude', claudeConfigurator);
this.tools.set('cline', clineConfigurator);
this.tools.set('agents', agentsConfigurator);
}
+37
View File
@@ -0,0 +1,37 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.augment/commands/openspec-proposal.md',
apply: '.augment/commands/openspec-apply.md',
archive: '.augment/commands/openspec-archive.md'
};
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
description: Scaffold a new OpenSpec change and validate strictly.
argument-hint: feature description or request
---`,
apply: `---
description: Implement an approved OpenSpec change and keep tasks in sync.
argument-hint: change-id
---`,
archive: `---
description: Archive a deployed OpenSpec change and update specs.
argument-hint: change-id
---`
};
export class AuggieSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'auggie';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
+27
View File
@@ -0,0 +1,27 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.clinerules/openspec-proposal.md',
apply: '.clinerules/openspec-apply.md',
archive: '.clinerules/openspec-archive.md'
};
export class ClineSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'cline';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
const descriptions: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
const description = descriptions[id];
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.crush/commands/openspec/proposal.md',
apply: '.crush/commands/openspec/apply.md',
archive: '.crush/commands/openspec/archive.md'
};
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: OpenSpec: Proposal
description: Scaffold a new OpenSpec change and validate strictly.
category: OpenSpec
tags: [openspec, change]
---`,
apply: `---
name: OpenSpec: Apply
description: Implement an approved OpenSpec change and keep tasks in sync.
category: OpenSpec
tags: [openspec, apply]
---`,
archive: `---
name: OpenSpec: Archive
description: Archive a deployed OpenSpec change and update specs.
category: OpenSpec
tags: [openspec, archive]
---`
};
export class CrushSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'crush';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
+9
View File
@@ -8,6 +8,9 @@ import { CodexSlashCommandConfigurator } from './codex.js';
import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
import { FactorySlashCommandConfigurator } from './factory.js';
import { AuggieSlashCommandConfigurator } from './auggie.js';
import { ClineSlashCommandConfigurator } from './cline.js';
import { CrushSlashCommandConfigurator } from './crush.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -22,6 +25,9 @@ export class SlashCommandRegistry {
const githubCopilot = new GitHubCopilotSlashCommandConfigurator();
const amazonQ = new AmazonQSlashCommandConfigurator();
const factory = new FactorySlashCommandConfigurator();
const auggie = new AuggieSlashCommandConfigurator();
const cline = new ClineSlashCommandConfigurator();
const crush = new CrushSlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(cursor.toolId, cursor);
@@ -32,6 +38,9 @@ export class SlashCommandRegistry {
this.configurators.set(githubCopilot.toolId, githubCopilot);
this.configurators.set(amazonQ.toolId, amazonQ);
this.configurators.set(factory.toolId, factory);
this.configurators.set(auggie.toolId, auggie);
this.configurators.set(cline.toolId, cline);
this.configurators.set(crush.toolId, crush);
}
static register(configurator: SlashCommandConfigurator): void {
+34 -7
View File
@@ -101,6 +101,12 @@ export interface DeltaPlan {
modified: RequirementBlock[];
removed: string[]; // requirement names
renamed: Array<{ from: string; to: string }>;
sectionPresence: {
added: boolean;
modified: boolean;
removed: boolean;
renamed: boolean;
};
}
function normalizeLineEndings(content: string): string {
@@ -113,11 +119,26 @@ function normalizeLineEndings(content: string): string {
export function parseDeltaSpec(content: string): DeltaPlan {
const normalized = normalizeLineEndings(content);
const sections = splitTopLevelSections(normalized);
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
const renamedPairs = parseRenamedPairs(sections['RENAMED Requirements'] || '');
return { added, modified, removed: removedNames, renamed: renamedPairs };
const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
const added = parseRequirementBlocksFromSection(addedLookup.body);
const modified = parseRequirementBlocksFromSection(modifiedLookup.body);
const removedNames = parseRemovedNames(removedLookup.body);
const renamedPairs = parseRenamedPairs(renamedLookup.body);
return {
added,
modified,
removed: removedNames,
renamed: renamedPairs,
sectionPresence: {
added: addedLookup.found,
modified: modifiedLookup.found,
removed: removedLookup.found,
renamed: renamedLookup.found,
},
};
}
function splitTopLevelSections(content: string): Record<string, string> {
@@ -140,6 +161,14 @@ function splitTopLevelSections(content: string): Record<string, string> {
return result;
}
function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {
const target = desired.toLowerCase();
for (const [title, body] of Object.entries(sections)) {
if (title.toLowerCase() === target) return { body, found: true };
}
return { body: '', found: false };
}
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
if (!sectionBody) return [];
const lines = normalizeLineEndings(sectionBody).split('\n');
@@ -203,5 +232,3 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin
}
return pairs;
}
+1
View File
@@ -0,0 +1 @@
export { agentsRootStubTemplate as clineTemplate } from './agents-root-stub.js';
+5
View File
@@ -1,6 +1,7 @@
import { agentsTemplate } from './agents-template.js';
import { projectTemplate, ProjectContext } from './project-template.js';
import { claudeTemplate } from './claude-template.js';
import { clineTemplate } from './cline-template.js';
import { agentsRootStubTemplate } from './agents-root-stub.js';
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
@@ -27,6 +28,10 @@ export class TemplateManager {
return claudeTemplate;
}
static getClineTemplate(): string {
return clineTemplate;
}
static getAgentsStandardTemplate(): string {
return agentsRootStubTemplate;
}
+56 -6
View File
@@ -114,6 +114,8 @@ export class Validator {
const issues: ValidationIssue[] = [];
const specsDir = path.join(changeDir, 'specs');
let totalDeltas = 0;
const missingHeaderSpecs: string[] = [];
const emptySectionSpecs: Array<{ path: string; sections: string[] }> = [];
try {
const entries = await fs.readdir(specsDir, { withFileTypes: true });
@@ -130,6 +132,17 @@ export class Validator {
const plan = parseDeltaSpec(content);
const entryPath = `${specName}/spec.md`;
const sectionNames: string[] = [];
if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements');
if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements');
if (plan.sectionPresence.removed) sectionNames.push('## REMOVED Requirements');
if (plan.sectionPresence.renamed) sectionNames.push('## RENAMED Requirements');
const hasSections = sectionNames.length > 0;
const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
if (!hasEntries) {
if (hasSections) emptySectionSpecs.push({ path: entryPath, sections: sectionNames });
else missingHeaderSpecs.push(entryPath);
}
const addedNames = new Set<string>();
const modifiedNames = new Set<string>();
@@ -236,6 +249,21 @@ export class Validator {
// If no specs dir, treat as no deltas
}
for (const { path: specPath, sections } of emptySectionSpecs) {
issues.push({
level: 'ERROR',
path: specPath,
message: `Delta sections ${this.formatSectionList(sections)} were found, but no requirement entries parsed. Ensure each section includes at least one "### Requirement:" block (REMOVED may use bullet list syntax).`,
});
}
for (const path of missingHeaderSpecs) {
issues.push({
level: 'ERROR',
path,
message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
});
}
if (totalDeltas === 0) {
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
}
@@ -375,16 +403,30 @@ export class Validator {
private extractRequirementText(blockRaw: string): string | undefined {
const lines = blockRaw.split('\n');
// Skip header
// Skip header line (index 0)
let i = 1;
const bodyLines: string[] = [];
// Find the first substantial text line, skipping metadata and blank lines
for (; i < lines.length; i++) {
const line = lines[i];
if (/^####\s+/.test(line)) break; // scenarios start
bodyLines.push(line);
// Stop at scenario headers
if (/^####\s+/.test(line)) break;
const trimmed = line.trim();
// Skip blank lines
if (trimmed.length === 0) continue;
// Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.)
if (/^\*\*[^*]+\*\*:/.test(trimmed)) continue;
// Found first non-metadata, non-blank line - this is the requirement text
return trimmed;
}
const text = bodyLines.join('\n').split('\n').map(l => l.trim()).find(l => l.length > 0);
return text;
// No requirement text found
return undefined;
}
private containsShallOrMust(text: string): boolean {
@@ -395,4 +437,12 @@ export class Validator {
const matches = blockRaw.match(/^####\s+/gm);
return matches ? matches.length : 0;
}
private formatSectionList(sections: string[]): string {
if (sections.length === 0) return '';
if (sections.length === 1) return sections[0];
const head = sections.slice(0, -1);
const last = sections[sections.length - 1];
return `${head.join(', ')} and ${last}`;
}
}
+42 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ArchiveCommand } from '../../src/core/archive.js';
import { Validator } from '../../src/core/validation/validator.js';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
@@ -215,6 +216,46 @@ Then expected result happens`;
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
});
it('should skip validation when commander sets validate to false (--no-validate)', async () => {
const changeName = 'skip-validation-flag';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'unstable-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
const deltaSpec = `# Unstable Capability
## ADDED Requirements
### Requirement: Logging Feature
**ID**: REQ-LOG-001
The system will log all events.
#### Scenario: Event recorded
- **WHEN** an event occurs
- **THEN** it is captured`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaSpec);
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n');
const deltaSpy = vi.spyOn(Validator.prototype, 'validateChangeDeltaSpecs');
const specContentSpy = vi.spyOn(Validator.prototype, 'validateSpecContent');
try {
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, validate: false });
expect(deltaSpy).not.toHaveBeenCalled();
expect(specContentSpy).not.toHaveBeenCalled();
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
} finally {
deltaSpy.mockRestore();
specContentSpy.mockRestore();
}
});
it('should proceed with archive when user declines spec updates', async () => {
const { confirm } = await import('@inquirer/prompts');
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
@@ -636,4 +677,4 @@ E1 updated`);
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
});
});
});
+186
View File
@@ -136,6 +136,39 @@ describe('InitCommand', () => {
expect(updatedContent).toContain('Custom instructions here');
});
it('should create CLINE.md when Cline is selected', async () => {
queueSelections('cline', DONE);
await initCommand.execute(testDir);
const clinePath = path.join(testDir, 'CLINE.md');
expect(await fileExists(clinePath)).toBe(true);
const content = await fs.readFile(clinePath, 'utf-8');
expect(content).toContain('<!-- OPENSPEC:START -->');
expect(content).toContain("@/openspec/AGENTS.md");
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
});
it('should update existing CLINE.md with markers', async () => {
queueSelections('cline', DONE);
const clinePath = path.join(testDir, 'CLINE.md');
const existingContent =
'# My Cline Rules\nCustom Cline instructions here';
await fs.writeFile(clinePath, existingContent);
await initCommand.execute(testDir);
const updatedContent = await fs.readFile(clinePath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain('Custom Cline instructions here');
});
it('should create Windsurf workflows when Windsurf is selected', async () => {
queueSelections('windsurf', DONE);
@@ -316,6 +349,45 @@ describe('InitCommand', () => {
expect(archiveContent).toContain('openspec list --specs');
});
it('should create Cline rule files with templates', async () => {
queueSelections('cline', DONE);
await initCommand.execute(testDir);
const clineProposal = path.join(
testDir,
'.clinerules/openspec-proposal.md'
);
const clineApply = path.join(
testDir,
'.clinerules/openspec-apply.md'
);
const clineArchive = path.join(
testDir,
'.clinerules/openspec-archive.md'
);
expect(await fileExists(clineProposal)).toBe(true);
expect(await fileExists(clineApply)).toBe(true);
expect(await fileExists(clineArchive)).toBe(true);
const proposalContent = await fs.readFile(clineProposal, 'utf-8');
expect(proposalContent).toContain('# OpenSpec: Proposal');
expect(proposalContent).toContain('Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
const applyContent = await fs.readFile(clineApply, 'utf-8');
expect(applyContent).toContain('# OpenSpec: Apply');
expect(applyContent).toContain('Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(clineArchive, 'utf-8');
expect(archiveContent).toContain('# OpenSpec: Archive');
expect(archiveContent).toContain('Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('openspec archive <id>');
});
it('should create Factory slash command files with templates', async () => {
queueSelections('factory', DONE);
@@ -683,6 +755,120 @@ describe('InitCommand', () => {
);
expect(amazonQChoice.configured).toBe(true);
});
it('should create Auggie slash command files with templates', async () => {
queueSelections('auggie', DONE);
await initCommand.execute(testDir);
const auggieProposal = path.join(
testDir,
'.augment/commands/openspec-proposal.md'
);
const auggieApply = path.join(
testDir,
'.augment/commands/openspec-apply.md'
);
const auggieArchive = path.join(
testDir,
'.augment/commands/openspec-archive.md'
);
expect(await fileExists(auggieProposal)).toBe(true);
expect(await fileExists(auggieApply)).toBe(true);
expect(await fileExists(auggieArchive)).toBe(true);
const proposalContent = await fs.readFile(auggieProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('argument-hint: feature description or request');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
const applyContent = await fs.readFile(auggieApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('argument-hint: change-id');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(auggieArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('argument-hint: change-id');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
it('should mark Auggie as already configured during extend mode', async () => {
queueSelections('auggie', DONE, 'auggie', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const auggieChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'auggie'
);
expect(auggieChoice.configured).toBe(true);
});
it('should create Crush slash command files with templates', async () => {
queueSelections('crush', DONE);
await initCommand.execute(testDir);
const crushProposal = path.join(
testDir,
'.crush/commands/openspec/proposal.md'
);
const crushApply = path.join(
testDir,
'.crush/commands/openspec/apply.md'
);
const crushArchive = path.join(
testDir,
'.crush/commands/openspec/archive.md'
);
expect(await fileExists(crushProposal)).toBe(true);
expect(await fileExists(crushApply)).toBe(true);
expect(await fileExists(crushArchive)).toBe(true);
const proposalContent = await fs.readFile(crushProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('name: OpenSpec: Proposal');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('category: OpenSpec');
expect(proposalContent).toContain('tags: [openspec, change]');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
const applyContent = await fs.readFile(crushApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('name: OpenSpec: Apply');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('category: OpenSpec');
expect(applyContent).toContain('tags: [openspec, apply]');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(crushArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('name: OpenSpec: Archive');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('category: OpenSpec');
expect(archiveContent).toContain('tags: [openspec, archive]');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
it('should mark Crush as already configured during extend mode', async () => {
queueSelections('crush', DONE, 'crush', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const crushChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'crush'
);
expect(crushChoice.configured).toBe(true);
});
});
describe('non-interactive mode', () => {
+228
View File
@@ -126,6 +126,95 @@ Old slash content
expect(fileExists).toBe(false);
});
it('should update only existing CLINE.md file', async () => {
// Create CLINE.md file with initial content
const clinePath = path.join(testDir, 'CLINE.md');
const initialContent = `# Cline Rules
Some existing Cline rules here.
<!-- OPENSPEC:START -->
Old OpenSpec content
<!-- OPENSPEC:END -->
More rules after.`;
await fs.writeFile(clinePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
// Execute update command
await updateCommand.execute(testDir);
// Check that CLINE.md was updated
const updatedContent = await fs.readFile(clinePath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('Some existing Cline rules here');
expect(updatedContent).toContain('More rules after');
// Check console output
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain('Updated AI tool files: CLINE.md');
consoleSpy.mockRestore();
});
it('should not create CLINE.md if it does not exist', async () => {
// Ensure CLINE.md does not exist
const clinePath = path.join(testDir, 'CLINE.md');
// Execute update command
await updateCommand.execute(testDir);
// Check that CLINE.md was not created
const fileExists = await FileSystemUtils.fileExists(clinePath);
expect(fileExists).toBe(false);
});
it('should refresh existing Cline rule files', async () => {
const proposalPath = path.join(
testDir,
'.clinerules/openspec-proposal.md'
);
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
const initialContent = `# OpenSpec: Proposal
Scaffold a new OpenSpec change and validate strictly.
<!-- OPENSPEC:START -->
Old slash content
<!-- OPENSPEC:END -->`;
await fs.writeFile(proposalPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(proposalPath, 'utf-8');
expect(updated).toContain('# OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
);
expect(updated).not.toContain('Old slash content');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain(
'Updated slash commands: .clinerules/openspec-proposal.md'
);
consoleSpy.mockRestore();
});
it('should refresh existing Cursor slash command files', async () => {
const cursorPath = path.join(testDir, '.cursor/commands/openspec-apply.md');
await fs.mkdir(path.dirname(cursorPath), { recursive: true });
@@ -513,6 +602,145 @@ Old body
await expect(FileSystemUtils.fileExists(aqArchive)).resolves.toBe(false);
});
it('should refresh existing Auggie slash command files', async () => {
const auggiePath = path.join(
testDir,
'.augment/commands/openspec-apply.md'
);
await fs.mkdir(path.dirname(auggiePath), { recursive: true });
const initialContent = `---
description: Implement an approved OpenSpec change and keep tasks in sync.
argument-hint: change-id
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(auggiePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updatedContent = await fs.readFile(auggiePath, 'utf-8');
expect(updatedContent).toContain('**Guardrails**');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).not.toContain('Old body');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('.augment/commands/openspec-apply.md')
);
consoleSpy.mockRestore();
});
it('should not create missing Auggie slash command files on update', async () => {
const auggieApply = path.join(
testDir,
'.augment/commands/openspec-apply.md'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(auggieApply), { recursive: true });
await fs.writeFile(
auggieApply,
'---\ndescription: Old\nargument-hint: old\n---\n<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);
await updateCommand.execute(testDir);
const auggieProposal = path.join(
testDir,
'.augment/commands/openspec-proposal.md'
);
const auggieArchive = path.join(
testDir,
'.augment/commands/openspec-archive.md'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(auggieProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(auggieArchive)).resolves.toBe(false);
});
it('should refresh existing Crush slash command files', async () => {
const crushPath = path.join(
testDir,
'.crush/commands/openspec/proposal.md'
);
await fs.mkdir(path.dirname(crushPath), { recursive: true });
const initialContent = `---
name: OpenSpec: Proposal
description: Old description
category: OpenSpec
tags: [openspec, change]
---
<!-- OPENSPEC:START -->
Old slash content
<!-- OPENSPEC:END -->`;
await fs.writeFile(crushPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(crushPath, 'utf-8');
expect(updated).toContain('name: OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
);
expect(updated).not.toContain('Old slash content');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain(
'Updated slash commands: .crush/commands/openspec/proposal.md'
);
consoleSpy.mockRestore();
});
it('should not create missing Crush slash command files on update', async () => {
const crushApply = path.join(
testDir,
'.crush/commands/openspec-apply.md'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(crushApply), { recursive: true });
await fs.writeFile(
crushApply,
`---
name: OpenSpec: Apply
description: Old description
category: OpenSpec
tags: [openspec, apply]
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const crushProposal = path.join(
testDir,
'.crush/commands/openspec-proposal.md'
);
const crushArchive = path.join(
testDir,
'.crush/commands/openspec-archive.md'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(crushProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(crushArchive)).resolves.toBe(false);
});
it('should preserve Windsurf content outside markers during update', async () => {
const wsPath = path.join(
testDir,
+153 -5
View File
@@ -306,10 +306,10 @@ Then result`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const validator = new Validator(true); // strict mode
const report = await validator.validateSpec(specPath);
expect(report.valid).toBe(false); // Should fail due to brief overview warning
});
@@ -330,12 +330,160 @@ Then result`;
const specPath = path.join(testDir, 'spec.md');
await fs.writeFile(specPath, specContent);
const validator = new Validator(false); // non-strict mode
const report = await validator.validateSpec(specPath);
expect(report.valid).toBe(true); // Should pass despite warnings
expect(report.summary.warnings).toBeGreaterThan(0);
});
});
});
describe('validateChangeDeltaSpecs with metadata', () => {
it('should validate requirement with metadata before SHALL/MUST text', async () => {
const changeDir = path.join(testDir, 'test-change');
const specsDir = path.join(changeDir, 'specs', 'test-spec');
await fs.mkdir(specsDir, { recursive: true });
const deltaSpec = `# Test Spec
## ADDED Requirements
### Requirement: Circuit Breaker State Management SHALL be implemented
**ID**: REQ-CB-001
**Priority**: P1 (High)
The system MUST implement a circuit breaker with three states.
#### Scenario: Normal operation
**Given** the circuit breaker is in CLOSED state
**When** a request is made
**Then** the request is executed normally`;
const specPath = path.join(specsDir, 'spec.md');
await fs.writeFile(specPath, deltaSpec);
const validator = new Validator(true);
const report = await validator.validateChangeDeltaSpecs(changeDir);
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
});
it('should validate requirement with SHALL in text but not in header', async () => {
const changeDir = path.join(testDir, 'test-change-2');
const specsDir = path.join(changeDir, 'specs', 'test-spec');
await fs.mkdir(specsDir, { recursive: true });
const deltaSpec = `# Test Spec
## ADDED Requirements
### Requirement: Error Handling
**ID**: REQ-ERR-001
**Priority**: P2
The system SHALL handle all errors gracefully.
#### Scenario: Error occurs
**Given** an error condition
**When** an error occurs
**Then** the error is logged and user is notified`;
const specPath = path.join(specsDir, 'spec.md');
await fs.writeFile(specPath, deltaSpec);
const validator = new Validator(true);
const report = await validator.validateChangeDeltaSpecs(changeDir);
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
});
it('should fail when requirement text lacks SHALL/MUST', async () => {
const changeDir = path.join(testDir, 'test-change-3');
const specsDir = path.join(changeDir, 'specs', 'test-spec');
await fs.mkdir(specsDir, { recursive: true });
const deltaSpec = `# Test Spec
## ADDED Requirements
### Requirement: Logging Feature
**ID**: REQ-LOG-001
The system will log all events.
#### Scenario: Event occurs
**Given** an event
**When** it occurs
**Then** it is logged`;
const specPath = path.join(specsDir, 'spec.md');
await fs.writeFile(specPath, deltaSpec);
const validator = new Validator(true);
const report = await validator.validateChangeDeltaSpecs(changeDir);
expect(report.valid).toBe(false);
expect(report.summary.errors).toBeGreaterThan(0);
expect(report.issues.some(i => i.message.includes('must contain SHALL or MUST'))).toBe(true);
});
it('should handle requirements without metadata fields', async () => {
const changeDir = path.join(testDir, 'test-change-4');
const specsDir = path.join(changeDir, 'specs', 'test-spec');
await fs.mkdir(specsDir, { recursive: true });
const deltaSpec = `# Test Spec
## ADDED Requirements
### Requirement: Simple Feature
The system SHALL implement this feature.
#### Scenario: Basic usage
**Given** a condition
**When** an action occurs
**Then** a result happens`;
const specPath = path.join(specsDir, 'spec.md');
await fs.writeFile(specPath, deltaSpec);
const validator = new Validator(true);
const report = await validator.validateChangeDeltaSpecs(changeDir);
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
});
it('should treat delta headers case-insensitively', async () => {
const changeDir = path.join(testDir, 'test-change-mixed-case');
const specsDir = path.join(changeDir, 'specs', 'test-spec');
await fs.mkdir(specsDir, { recursive: true });
const deltaSpec = `# Test Spec
## Added Requirements
### Requirement: Mixed Case Handling
The system MUST support mixed case delta headers.
#### Scenario: Case insensitive parsing
**Given** a delta file with mixed case headers
**When** validation runs
**Then** the delta is detected`;
const specPath = path.join(specsDir, 'spec.md');
await fs.writeFile(specPath, deltaSpec);
const validator = new Validator(true);
const report = await validator.validateChangeDeltaSpecs(changeDir);
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
expect(report.summary.warnings).toBe(0);
expect(report.summary.info).toBe(0);
});
});
});