mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac659f7993 | ||
|
|
ece61a6d68 | ||
|
|
ecddffc22e | ||
|
|
822464ec44 | ||
|
|
67ab683105 | ||
|
|
f82e243551 | ||
|
|
88b260d51f | ||
|
|
ce7422209f | ||
|
|
63b8a3e9f9 | ||
|
|
4cf7bf863d |
@@ -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:
|
||||
- ".*"
|
||||
@@ -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
|
||||
@@ -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...'"
|
||||
}
|
||||
@@ -147,3 +147,6 @@ docs/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.DS_Store
|
||||
|
||||
# Pnpm
|
||||
.pnpm-store/
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+3
-3
@@ -60,7 +60,7 @@ Track these steps as TODOs and complete them one by one.
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -97,7 +97,7 @@ openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -450,7 +450,7 @@ openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Add Archive Command Arguments
|
||||
|
||||
## Why
|
||||
The `/openspec:archive` slash command currently lacks argument support, forcing the AI to infer which change to archive from conversation context or by listing all changes. This creates a safety risk where the wrong proposal could be archived if the context is ambiguous or multiple changes exist. Users expect to specify the change ID explicitly, matching the behavior of the CLI command `openspec archive <id>`.
|
||||
|
||||
## What Changes
|
||||
- Add `$ARGUMENTS` placeholder to the OpenCode archive slash command frontmatter (matching existing pattern for proposal command)
|
||||
- Update archive command template steps to validate the specific change ID argument when provided
|
||||
- Note: Codex, GitHub Copilot, and Amazon Q already have `$ARGUMENTS` for archive; Claude/Cursor/Windsurf/Kilocode don't support arguments
|
||||
|
||||
## Impact
|
||||
- Affected specs: `cli-update` (slash command generation logic)
|
||||
- Affected code:
|
||||
- `src/core/configurators/slash/opencode.ts` (add `$ARGUMENTS` to archive frontmatter)
|
||||
- `src/core/templates/slash-command-templates.ts` (archive template steps for argument validation)
|
||||
- Breaking: No - this is additive functionality that makes the command safer
|
||||
- User-facing: Yes - OpenCode users will be able to pass the change ID as an argument: `/openspec:archive <change-id>`
|
||||
@@ -0,0 +1,34 @@
|
||||
# CLI Update Specification Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Archive Command Argument Support
|
||||
The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.
|
||||
|
||||
#### Scenario: Archive command with change ID argument
|
||||
- **WHEN** a user invokes `/openspec:archive <change-id>` with a change ID
|
||||
- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list`
|
||||
- **AND** use the provided change ID for archiving if valid
|
||||
- **AND** fail fast if the provided change ID doesn't match an archivable change
|
||||
|
||||
#### Scenario: Archive command without argument (backward compatibility)
|
||||
- **WHEN** a user invokes `/openspec:archive` without providing a change ID
|
||||
- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list`
|
||||
- **AND** proceed with the existing behavior (maintaining backward compatibility)
|
||||
|
||||
#### Scenario: OpenCode archive template generation
|
||||
- **WHEN** generating the OpenCode archive slash command file
|
||||
- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter
|
||||
- **AND** wrap it in a clear structure like `<ChangeId>\n $ARGUMENTS\n</ChangeId>` to indicate the expected argument
|
||||
- **AND** include validation steps in the template body to check if the change ID is valid
|
||||
@@ -0,0 +1,21 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Update OpenCode Configurator
|
||||
- [x] 1.1 Add `$ARGUMENTS` placeholder to OpenCode archive frontmatter (matching the proposal pattern)
|
||||
- [x] 1.2 Format it as `<ChangeId>\n $ARGUMENTS\n</ChangeId>` or similar structure for clarity
|
||||
- [x] 1.3 Ensure `updateExisting` rewrites the archive frontmatter/body so `$ARGUMENTS` persists after `openspec update`
|
||||
|
||||
## 2. Update Slash Command Templates
|
||||
- [x] 2.1 Modify archive steps to validate change ID argument when provided via `$ARGUMENTS`
|
||||
- [x] 2.2 Keep backward compatibility - allow inferring from context if no argument provided
|
||||
- [x] 2.3 Add step to validate the change ID exists using `openspec list` before archiving
|
||||
|
||||
## 3. Update Documentation
|
||||
- [x] 3.1 Update AGENTS.md archive examples to show argument usage
|
||||
- [x] 3.2 Document that OpenCode now supports `/openspec:archive <change-id>`
|
||||
|
||||
## 4. Validation and Testing
|
||||
- [ ] 4.1 Run `openspec update` to regenerate OpenCode slash commands
|
||||
- [ ] 4.2 Manually test with OpenCode using `/openspec:archive <change-id>`
|
||||
- [ ] 4.3 Test backward compatibility (archive command without arguments)
|
||||
- [ ] 4.4 Run `openspec validate --strict` to ensure no issues
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId } from "../../templates/index.js";
|
||||
import { FileSystemUtils } from "../../../utils/file-system.js";
|
||||
import { OPENSPEC_MARKERS } from "../../config.js";
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".opencode/command/openspec-proposal.md",
|
||||
@@ -24,7 +26,11 @@ description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
archive: `---
|
||||
agent: build
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`,
|
||||
---
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>
|
||||
`,
|
||||
};
|
||||
|
||||
export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
@@ -38,4 +44,38 @@ export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated = await super.generateAll(projectPath, _openspecDir);
|
||||
await this.rewriteArchiveFile(projectPath);
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const updated = await super.updateExisting(projectPath, _openspecDir);
|
||||
const rewroteArchive = await this.rewriteArchiveFile(projectPath);
|
||||
if (rewroteArchive && !updated.includes(FILE_PATHS.archive)) {
|
||||
updated.push(FILE_PATHS.archive);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async rewriteArchiveFile(projectPath: string): Promise<boolean> {
|
||||
const archivePath = FileSystemUtils.joinPath(projectPath, FILE_PATHS.archive);
|
||||
if (!await FileSystemUtils.fileExists(archivePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = this.getBody("archive");
|
||||
const frontmatter = this.getFrontmatter("archive");
|
||||
const sections: string[] = [];
|
||||
|
||||
if (frontmatter) {
|
||||
sections.push(frontmatter.trim());
|
||||
}
|
||||
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
await FileSystemUtils.writeFile(archivePath, sections.join("\n") + "\n");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Track these steps as TODOs and complete them one by one.
|
||||
After deployment, create separate PR to:
|
||||
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
- Update \`specs/\` if capabilities changed
|
||||
- Use \`openspec archive [change] --skip-specs --yes\` for tooling-only changes
|
||||
- Use \`openspec archive <change-id> --skip-specs --yes\` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run \`openspec validate --strict\` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -97,7 +97,7 @@ openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -450,7 +450,7 @@ openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
\`\`\`
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { agentsRootStubTemplate as clineTemplate } from './agents-root-stub.js';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -33,12 +33,18 @@ const applyReferences = `**Reference**
|
||||
- Use \`openspec show <id> --json --deltas-only\` if you need additional context from the proposal while implementing.`;
|
||||
|
||||
const archiveSteps = `**Steps**
|
||||
1. Identify the requested change ID (via the prompt or \`openspec list\`).
|
||||
2. Run \`openspec archive <id> --yes\` to let the CLI move the change and apply spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
3. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
4. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
1. Determine the change ID to archive:
|
||||
- If this prompt already includes a specific change ID (for example inside a \`<ChangeId>\` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||
- If the conversation references a change loosely (for example by title or summary), run \`openspec list\` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||
- Otherwise, review the conversation, run \`openspec list\`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
|
||||
- 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 <id>\`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||
3. Run \`openspec archive <id> --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/\`.
|
||||
5. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
|
||||
const archiveReferences = `**Reference**
|
||||
- Use \`openspec list\` to confirm change IDs before archiving.
|
||||
- Inspect refreshed specs with \`openspec list --specs\` and address any validation issues before handing off.`;
|
||||
|
||||
export const slashCommandBodies: Record<SlashCommandId, string> = {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user