mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3f64e3a0 | ||
|
|
b2c2799939 |
@@ -140,6 +140,8 @@ dist/
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Internal Docs
|
||||
docs/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 455c65f: Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
# POC-OpenSpec-Core Analysis
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions & Terminology
|
||||
|
||||
### Philosophy: Not a Workflow System
|
||||
|
||||
This system is **not** a workflow engine. It's an **artifact tracker with dependency awareness**.
|
||||
|
||||
| What it's NOT | What it IS |
|
||||
|---------------|------------|
|
||||
| Linear step-by-step progression | Exploratory, iterative planning |
|
||||
| Bureaucratic checkpoints | Enablers that unlock possibilities |
|
||||
| "You must complete step 1 first" | "Here's what you could create now" |
|
||||
| Form-filling | Fluid document creation |
|
||||
|
||||
**Key insight:** Dependencies are *enablers*, not *gates*. You can't meaningfully write a design document if there's no proposal to design from - that's not bureaucracy, it's logic.
|
||||
|
||||
### Terminology
|
||||
|
||||
| Term | Definition | Example |
|
||||
|------|------------|---------|
|
||||
| **Change** | A unit of work being planned (feature, refactor, migration) | `openspec/changes/add-auth/` |
|
||||
| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `spec-driven.yaml` |
|
||||
| **Artifact** | A node in the graph (a document to create) | `proposal`, `design`, `specs` |
|
||||
| **Template** | Instructions/guidance for creating an artifact | `templates/proposal.md` |
|
||||
|
||||
### Hierarchy
|
||||
|
||||
```
|
||||
Schema (defines) ──→ Artifacts (guided by) ──→ Templates
|
||||
```
|
||||
|
||||
- **Schema** = the artifact graph (what exists, dependencies)
|
||||
- **Artifact** = a document to produce
|
||||
- **Template** = instructions for creating that artifact
|
||||
|
||||
### Schema Variations
|
||||
|
||||
Schemas can vary across multiple dimensions:
|
||||
|
||||
| Dimension | Examples |
|
||||
|-----------|----------|
|
||||
| Philosophy | `spec-driven`, `tdd`, `prototype-first` |
|
||||
| Version | `v1`, `v2`, `v3` |
|
||||
| Language | `en`, `zh`, `es` |
|
||||
| Custom | `team-alpha`, `experimental` |
|
||||
|
||||
### Schema Resolution (XDG Standard)
|
||||
|
||||
Schemas follow the XDG Base Directory Specification with a 2-level resolution:
|
||||
|
||||
```
|
||||
1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # Global user override
|
||||
2. <package>/schemas/<name>/schema.yaml # Built-in defaults
|
||||
```
|
||||
|
||||
**Platform-specific paths:**
|
||||
- Unix/macOS: `~/.local/share/openspec/schemas/`
|
||||
- Windows: `%LOCALAPPDATA%/openspec/schemas/`
|
||||
- All platforms: `$XDG_DATA_HOME/openspec/schemas/` (when set)
|
||||
|
||||
**Why XDG?**
|
||||
- Schemas are workflow definitions (data), not user preferences (config)
|
||||
- Built-ins baked into package, never auto-copied
|
||||
- Users customize by creating files in global data dir
|
||||
- Consistent with modern CLI tooling standards
|
||||
|
||||
### Template Inheritance (2 Levels Max)
|
||||
|
||||
Templates are co-located with schemas in a `templates/` subdirectory:
|
||||
|
||||
```
|
||||
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
|
||||
2. <package>/schemas/<schema>/templates/<artifact>.md # Built-in
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- User overrides take precedence over package built-ins
|
||||
- A CLI command shows resolved paths (no guessing)
|
||||
- No inheritance between schemas (copy if you need to diverge)
|
||||
- Templates are always co-located with their schema
|
||||
|
||||
**Why this matters:**
|
||||
- Avoids "where does this come from?" debugging
|
||||
- No implicit magic that works until it doesn't
|
||||
- Schema + templates form a cohesive unit
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This is an **artifact tracker with dependency awareness** that guides iterative development through a structured artifact pipeline. The core innovation is using the **filesystem as a database** - artifact completion is detected by file existence, making the system stateless and version-control friendly.
|
||||
|
||||
The system answers:
|
||||
- "What artifacts exist for this change?"
|
||||
- "What could I create next?" (not "what must I create")
|
||||
- "What's blocking X?" (informational, not prescriptive)
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. ArtifactGraph (Slice 1 - COMPLETE)
|
||||
|
||||
The dependency graph engine with XDG-compliant schema resolution.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Model artifacts as a DAG | Artifact with `requires: string[]` |
|
||||
| Track completion state | `Set<string>` for completed artifacts |
|
||||
| Calculate build order | Kahn's algorithm (topological sort) |
|
||||
| Find ready artifacts | Check if all dependencies are in `completed` set |
|
||||
| Resolve schemas | XDG global → package built-ins |
|
||||
|
||||
**Key Data Structures (Zod-validated):**
|
||||
|
||||
```typescript
|
||||
// Zod schemas define types + validation
|
||||
const ArtifactSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
|
||||
description: z.string(),
|
||||
template: z.string(), // path to template file
|
||||
requires: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.number().int().positive(),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1),
|
||||
});
|
||||
|
||||
// Derived types
|
||||
type Artifact = z.infer<typeof ArtifactSchema>;
|
||||
type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
- `resolveSchema(name)` - Load schema with XDG fallback
|
||||
- `ArtifactGraph.fromSchema(schema)` - Build graph from schema
|
||||
- `detectState(graph, changeDir)` - Scan filesystem for completion
|
||||
- `getNextArtifacts(graph, completed)` - Find artifacts ready to create
|
||||
- `getBuildOrder(graph)` - Topological sort of all artifacts
|
||||
- `getBlocked(graph, completed)` - Artifacts with unmet dependencies
|
||||
|
||||
---
|
||||
|
||||
### 2. Change Utilities (Slice 2)
|
||||
|
||||
Simple utility functions for programmatic change creation. No class, no abstraction layer.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Create changes | Create dirs under `openspec/changes/<name>/` with README |
|
||||
| Name validation | Enforce kebab-case naming |
|
||||
|
||||
**Key Paths:**
|
||||
|
||||
```
|
||||
openspec/changes/<name>/ → Change instances with artifacts (project-level)
|
||||
```
|
||||
|
||||
**Key Functions** (`src/utils/change-utils.ts`):
|
||||
- `createChange(projectRoot, name, description?)` - Create new change directory + README
|
||||
- `validateChangeName(name)` - Validate kebab-case naming, returns `{ valid, error? }`
|
||||
|
||||
**Note:** Existing CLI commands (`ListCommand`, `ChangeCommand`) already handle listing, path resolution, and existence checks. No need to extract that logic - it works fine as-is.
|
||||
|
||||
---
|
||||
|
||||
### 3. InstructionLoader (Slice 3)
|
||||
|
||||
Template resolution and instruction enrichment.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Resolve templates | XDG 2-level fallback (schema-specific → shared → built-in) |
|
||||
| Build dynamic context | Gather dependency status, change info |
|
||||
| Enrich templates | Inject context into base templates |
|
||||
| Generate status reports | Formatted markdown with progress |
|
||||
|
||||
**Key Class - ChangeState:**
|
||||
|
||||
```
|
||||
ChangeState {
|
||||
changeName: string
|
||||
changeDir: string
|
||||
graph: ArtifactGraph
|
||||
completed: Set<string>
|
||||
|
||||
// Methods
|
||||
getNextSteps(): string[]
|
||||
getStatus(artifactId): ArtifactStatus
|
||||
isComplete(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Key Functions:**
|
||||
- `getTemplatePath(artifactId, schemaName?)` - Resolve with 2-level fallback
|
||||
- `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point
|
||||
- `getChangeStatus(projectRoot, changeName?)` - Formatted status report
|
||||
|
||||
---
|
||||
|
||||
### 4. CLI (Slice 4)
|
||||
|
||||
User interface layer. **All commands are deterministic** - require explicit `--change` parameter.
|
||||
|
||||
| Command | Function | Status |
|
||||
|---------|----------|--------|
|
||||
| `status --change <id>` | Show change progress (artifact graph) | **NEW** |
|
||||
| `next --change <id>` | Show artifacts ready to create | **NEW** |
|
||||
| `instructions <artifact> --change <id>` | Get enriched instructions for artifact | **NEW** |
|
||||
| `list` | List all changes | EXISTS (`openspec change list`) |
|
||||
| `new <name>` | Create change | **NEW** (uses `createChange()`) |
|
||||
| `init` | Initialize structure | EXISTS (`openspec init`) |
|
||||
| `templates --change <id>` | Show resolved template paths | **NEW** |
|
||||
|
||||
**Note:** Commands that operate on a change require `--change`. Missing parameter → error with list of available changes. Agent infers the change from conversation and passes it explicitly.
|
||||
|
||||
**Existing CLI commands** (not part of this slice):
|
||||
- `openspec change list` / `openspec change show <id>` / `openspec change validate <id>`
|
||||
- `openspec list --changes` / `openspec list --specs`
|
||||
- `openspec view` (dashboard)
|
||||
- `openspec init` / `openspec archive <change>`
|
||||
|
||||
---
|
||||
|
||||
### 5. Claude Commands
|
||||
|
||||
Integration layer for Claude Code. **Operational commands only** - artifact creation via natural language.
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/status` | Show change progress |
|
||||
| `/next` | Show what's ready to create |
|
||||
| `/run [artifact]` | Execute a specific step (power users) |
|
||||
| `/list` | List all changes |
|
||||
| `/new <name>` | Create a new change |
|
||||
| `/init` | Initialize structure |
|
||||
|
||||
**Artifact creation:** Users say "create the proposal" or "write the tests" in natural language. The agent:
|
||||
1. Infers change from conversation (confirms if uncertain)
|
||||
2. Infers artifact from request
|
||||
3. Calls CLI with explicit `--change` parameter
|
||||
4. Creates artifact following instructions
|
||||
|
||||
This works for ANY artifact in ANY schema - no new slash commands needed when schemas change.
|
||||
|
||||
**Note:** Legacy commands (`/openspec-proposal`, `/openspec-apply`, `/openspec-archive`) exist in the main project for backward compatibility but are separate from this architecture.
|
||||
|
||||
---
|
||||
|
||||
## Component Dependency Graph
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION LAYER │
|
||||
│ ┌──────────────┐ ┌────────────────────┐ │
|
||||
│ │ CLI │ ←─shell exec───────│ Claude Commands │ │
|
||||
│ └──────┬───────┘ └────────────────────┘ │
|
||||
└─────────┼───────────────────────────────────────────────────┘
|
||||
│ imports
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ORCHESTRATION LAYER │
|
||||
│ ┌────────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ InstructionLoader │ │ change-utils (Slice 2) │ │
|
||||
│ │ (Slice 3) │ │ createChange() │ │
|
||||
│ └─────────┬──────────┘ │ validateChangeName() │ │
|
||||
│ │ └──────────────────────────┘ │
|
||||
└────────────┼────────────────────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CORE LAYER │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ ArtifactGraph (Slice 1) │ │
|
||||
│ │ │ │
|
||||
│ │ Schema Resolution (XDG) ──→ Graph ──→ State Detection│ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ reads from
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PERSISTENCE LAYER │
|
||||
│ ┌──────────────────┐ ┌────────────────────────────────┐ │
|
||||
│ │ XDG Schemas │ │ Project Artifacts │ │
|
||||
│ │ ~/.local/share/ │ │ openspec/changes/<name>/ │ │
|
||||
│ │ openspec/ │ │ - proposal.md, design.md │ │
|
||||
│ │ schemas/ │ │ - specs/*.md, tasks.md │ │
|
||||
│ └──────────────────┘ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Design Patterns
|
||||
|
||||
### 1. Filesystem as Database
|
||||
|
||||
No SQLite, no JSON state files. The existence of `proposal.md` means proposal is complete.
|
||||
|
||||
```
|
||||
// State detection is just file existence checking
|
||||
if (exists(artifactPath)) {
|
||||
completed.add(artifactId)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Deterministic CLI, Inferring Agent
|
||||
|
||||
**CLI layer:** Always deterministic - requires explicit `--change` parameter.
|
||||
|
||||
```
|
||||
openspec status --change add-auth # explicit, works
|
||||
openspec status # error: "No change specified"
|
||||
```
|
||||
|
||||
**Agent layer:** Infers from conversation, confirms if uncertain, passes explicit `--change`.
|
||||
|
||||
This separation means:
|
||||
- CLI is pure, testable, no state to corrupt
|
||||
- Agent handles all "smartness"
|
||||
- No config.yaml tracking of "active change"
|
||||
|
||||
### 3. XDG-Compliant Schema Resolution
|
||||
|
||||
```
|
||||
${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
|
||||
↓ (not found)
|
||||
<package>/schemas/<name>/schema.yaml # Built-in
|
||||
↓ (not found)
|
||||
Error (schema not found)
|
||||
```
|
||||
|
||||
### 4. Two-Level Template Fallback
|
||||
|
||||
```
|
||||
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
|
||||
↓ (not found)
|
||||
<package>/schemas/<schema>/templates/<artifact>.md # Built-in
|
||||
↓ (not found)
|
||||
Error (no silent fallback to avoid confusion)
|
||||
```
|
||||
|
||||
### 5. Glob Pattern Support
|
||||
|
||||
`specs/*.md` allows multiple files to satisfy a single artifact:
|
||||
|
||||
```
|
||||
if (artifact.generates.includes("*")) {
|
||||
const parentDir = changeDir / patternParts[0]
|
||||
if (exists(parentDir) && hasFiles(parentDir)) {
|
||||
completed.add(artifactId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Stateless State Detection
|
||||
|
||||
Every command re-scans the filesystem. No cached state to corrupt.
|
||||
|
||||
---
|
||||
|
||||
## Artifact Pipeline (Default Schema)
|
||||
|
||||
The default `spec-driven` schema:
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ proposal │ (no dependencies)
|
||||
└────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ specs │ (requires: proposal)
|
||||
└────┬─────┘
|
||||
│
|
||||
├──────────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ design │ │ │
|
||||
│ │◄──┤ proposal │
|
||||
└────┬─────┘ └──────────┘
|
||||
│ (requires: proposal, specs)
|
||||
▼
|
||||
┌──────────┐
|
||||
│ tasks │ (requires: design)
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
Other schemas (TDD, prototype-first) would have different graphs.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Structured as **vertical slices** - each slice is independently testable.
|
||||
|
||||
---
|
||||
|
||||
### Slice 1: "What's Ready?" (Core Query) ✅ COMPLETE
|
||||
|
||||
**Delivers:** Types + Graph + State Detection + Schema Resolution
|
||||
|
||||
**Implementation:** `src/core/artifact-graph/`
|
||||
- `types.ts` - Zod schemas and derived TypeScript types
|
||||
- `schema.ts` - YAML parsing with Zod validation
|
||||
- `graph.ts` - ArtifactGraph class with topological sort
|
||||
- `state.ts` - Filesystem-based state detection
|
||||
- `resolver.ts` - XDG-compliant schema resolution
|
||||
- `builtin-schemas.ts` - Package-bundled default schemas
|
||||
|
||||
**Key decisions made:**
|
||||
- Zod for schema validation (consistent with project)
|
||||
- XDG for global schema overrides
|
||||
- `Set<string>` for completion state (immutable, functional)
|
||||
- `inProgress` and `failed` states deferred (require external tracking)
|
||||
|
||||
---
|
||||
|
||||
### Slice 2: "Change Creation Utilities"
|
||||
|
||||
**Delivers:** Utility functions for programmatic change creation
|
||||
|
||||
**Scope:**
|
||||
- `createChange(projectRoot, name, description?)` → creates directory + README
|
||||
- `validateChangeName(name)` → kebab-case pattern enforcement
|
||||
|
||||
**Not in scope (already exists in CLI commands):**
|
||||
- `listChanges()` → exists in `ListCommand` and `ChangeCommand.getActiveChanges()`
|
||||
- `getChangePath()` → simple `path.join()` inline
|
||||
- `changeExists()` → simple `fs.access()` inline
|
||||
- `isInitialized()` → simple directory check inline
|
||||
|
||||
**Why simplified:** Extracting existing CLI logic into a class would require similar refactoring of `SpecCommand` for consistency. The existing code works fine (~15 lines each). Only truly new functionality is `createChange()` + name validation.
|
||||
|
||||
---
|
||||
|
||||
### Slice 3: "Get Instructions" (Enrichment)
|
||||
|
||||
**Delivers:** Template resolution + context injection
|
||||
|
||||
**Testable behaviors:**
|
||||
- Template fallback: schema-specific → shared → built-in → error
|
||||
- Context injection: completed deps show ✓, missing show ✗
|
||||
- Output path shown correctly based on change directory
|
||||
|
||||
---
|
||||
|
||||
### Slice 4: "CLI + Integration"
|
||||
|
||||
**Delivers:** New artifact graph commands (builds on existing CLI)
|
||||
|
||||
**New commands:**
|
||||
- `status --change <id>` - Show artifact completion state
|
||||
- `next --change <id>` - Show ready-to-create artifacts
|
||||
- `instructions <artifact> --change <id>` - Get enriched template
|
||||
- `templates --change <id>` - Show resolved paths
|
||||
- `new <name>` - Create change (wrapper for `createChange()`)
|
||||
|
||||
**Already exists (not in scope):**
|
||||
- `openspec change list/show/validate` - change management
|
||||
- `openspec list --changes/--specs` - listing
|
||||
- `openspec view` - dashboard
|
||||
- `openspec init` - initialization
|
||||
|
||||
**Testable behaviors:**
|
||||
- Each new command produces expected output
|
||||
- Commands compose correctly (status → next → instructions flow)
|
||||
- Error handling for missing changes, invalid artifacts, etc.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
# Global (XDG paths - user overrides)
|
||||
~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/)
|
||||
%LOCALAPPDATA%/openspec/ # Windows
|
||||
└── schemas/ # Schema overrides
|
||||
└── custom-workflow/ # User-defined schema directory
|
||||
├── schema.yaml # Schema definition
|
||||
└── templates/ # Co-located templates
|
||||
└── proposal.md
|
||||
|
||||
# Package (built-in defaults)
|
||||
<package>/
|
||||
└── schemas/ # Built-in schema definitions
|
||||
├── spec-driven/ # Default: proposal → specs → design → tasks
|
||||
│ ├── schema.yaml
|
||||
│ └── templates/
|
||||
│ ├── proposal.md
|
||||
│ ├── design.md
|
||||
│ ├── spec.md
|
||||
│ └── tasks.md
|
||||
└── tdd/ # TDD: tests → implementation → docs
|
||||
├── schema.yaml
|
||||
└── templates/
|
||||
├── test.md
|
||||
├── implementation.md
|
||||
├── spec.md
|
||||
└── docs.md
|
||||
|
||||
# Project (change instances)
|
||||
openspec/
|
||||
└── changes/ # Change instances
|
||||
├── add-auth/
|
||||
│ ├── README.md # Auto-generated on creation
|
||||
│ ├── proposal.md # Created artifacts
|
||||
│ ├── design.md
|
||||
│ └── specs/
|
||||
│ └── *.md
|
||||
├── refactor-db/
|
||||
│ └── ...
|
||||
└── archive/ # Completed changes
|
||||
└── 2025-01-01-add-auth/
|
||||
|
||||
.claude/
|
||||
├── settings.local.json # Permissions
|
||||
└── commands/ # Slash commands
|
||||
└── *.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema YAML Format
|
||||
|
||||
```yaml
|
||||
# Built-in: <package>/schemas/spec-driven/schema.yaml
|
||||
# Or user override: ~/.local/share/openspec/schemas/spec-driven/schema.yaml
|
||||
name: spec-driven
|
||||
version: 1
|
||||
description: Specification-driven development
|
||||
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: "proposal.md"
|
||||
description: "Create project proposal document"
|
||||
template: "proposal.md" # resolves from co-located templates/ directory
|
||||
requires: []
|
||||
|
||||
- id: specs
|
||||
generates: "specs/*.md" # glob pattern
|
||||
description: "Create technical specification documents"
|
||||
template: "specs.md"
|
||||
requires:
|
||||
- proposal
|
||||
|
||||
- id: design
|
||||
generates: "design.md"
|
||||
description: "Create design document"
|
||||
template: "design.md"
|
||||
requires:
|
||||
- proposal
|
||||
- specs
|
||||
|
||||
- id: tasks
|
||||
generates: "tasks.md"
|
||||
description: "Create tasks breakdown document"
|
||||
template: "tasks.md"
|
||||
requires:
|
||||
- design
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Layer | Component | Responsibility | Status |
|
||||
|-------|-----------|----------------|--------|
|
||||
| Core | ArtifactGraph | Pure dependency logic + XDG schema resolution | ✅ Slice 1 COMPLETE |
|
||||
| Utils | change-utils | Change creation + name validation only | Slice 2 (new functionality only) |
|
||||
| Core | InstructionLoader | Template resolution + enrichment | Slice 3 (all new) |
|
||||
| Presentation | CLI | New artifact graph commands | Slice 4 (new commands only) |
|
||||
| Integration | Claude Commands | AI assistant glue | Slice 4 |
|
||||
|
||||
**What already exists (not in this proposal):**
|
||||
- `getActiveChangeIds()` in `src/utils/item-discovery.ts` - list changes
|
||||
- `ChangeCommand.list/show/validate()` in `src/commands/change.ts`
|
||||
- `ListCommand.execute()` in `src/core/list.ts`
|
||||
- `ViewCommand.execute()` in `src/core/view.ts` - dashboard
|
||||
- `src/core/init.ts` - initialization
|
||||
- `src/core/archive.ts` - archiving
|
||||
|
||||
**Key Principles:**
|
||||
- **Filesystem IS the database** - stateless, version-control friendly
|
||||
- **Dependencies are enablers** - show what's possible, don't force order
|
||||
- **Deterministic CLI, inferring agent** - CLI requires explicit `--change`, agent infers from context
|
||||
- **XDG-compliant paths** - schemas and templates use standard user data directories
|
||||
- **2-level inheritance** - user override → package built-in (no deeper)
|
||||
- **Schemas are versioned** - support variations by philosophy, version, language
|
||||
@@ -1,197 +0,0 @@
|
||||
## Context
|
||||
|
||||
This implements "Slice 1: What's Ready?" from the artifact POC analysis. The core insight is using the filesystem as a database - artifact completion is detected by file existence, making the system stateless and version-control friendly.
|
||||
|
||||
This module will coexist with the current OpenSpec system as a parallel capability, potentially enabling future migration or integration.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Pure dependency graph logic with no side effects
|
||||
- Stateless state detection (rescan filesystem each query)
|
||||
- Support glob patterns for multi-file artifacts (e.g., `specs/*.md`)
|
||||
- Load artifact definitions from YAML schemas
|
||||
- Calculate topological build order
|
||||
- Determine "ready" artifacts based on dependency completion
|
||||
|
||||
**Non-Goals:**
|
||||
- CLI commands (Slice 4)
|
||||
- Multi-change management (Slice 2)
|
||||
- Template resolution and enrichment (Slice 3)
|
||||
- Agent integration or Claude commands
|
||||
- Replacing existing OpenSpec functionality
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Filesystem as Database
|
||||
Use file existence for state detection rather than a separate state file.
|
||||
|
||||
**Rationale:**
|
||||
- Stateless - no state corruption possible
|
||||
- Git-friendly - state derived from committed files
|
||||
- Simple - no sync issues between state file and actual files
|
||||
|
||||
**Alternatives considered:**
|
||||
- JSON/SQLite state file: More complex, sync issues, not git-friendly
|
||||
- Git metadata: Too coupled to git, complex implementation
|
||||
|
||||
### Decision: Kahn's Algorithm for Topological Sort
|
||||
Use Kahn's algorithm for computing build order.
|
||||
|
||||
**Rationale:**
|
||||
- Well-understood, O(V+E) complexity
|
||||
- Naturally detects cycles during execution
|
||||
- Produces a stable, deterministic order
|
||||
|
||||
### Decision: Glob Pattern Support
|
||||
Support glob patterns like `specs/*.md` in artifact `generates` field.
|
||||
|
||||
**Rationale:**
|
||||
- Allows multiple files to satisfy a single artifact requirement
|
||||
- Common pattern for spec directories with multiple files
|
||||
- Uses standard glob syntax
|
||||
|
||||
### Decision: Immutable Completed Set
|
||||
Represent completion state as an immutable Set of completed artifact IDs.
|
||||
|
||||
**Rationale:**
|
||||
- Functional style, easier to reason about
|
||||
- State derived fresh each query, no mutation needed
|
||||
- Clear separation between graph structure and runtime state
|
||||
- Filesystem can only detect binary existence (complete vs not complete)
|
||||
|
||||
**Note:** `inProgress` and `failed` states are deferred to future slices. They would require external state tracking (e.g., a status file) since file existence alone cannot distinguish these states.
|
||||
|
||||
### Decision: Zod for Schema Validation
|
||||
Use Zod for validating YAML schema structure and deriving TypeScript types.
|
||||
|
||||
**Rationale:**
|
||||
- Already a project dependency (v4.0.17) used in `src/core/schemas/`
|
||||
- Type inference via `z.infer<>` - single source of truth for types
|
||||
- Runtime validation with detailed error messages
|
||||
- Consistent with existing project patterns (`base.schema.ts`, `config-schema.ts`)
|
||||
|
||||
**Alternatives considered:**
|
||||
- Manual validation: More code, error-prone, no type inference
|
||||
- JSON Schema: Would require additional dependency, less TypeScript integration
|
||||
- io-ts: Not already in project, steeper learning curve
|
||||
|
||||
### Decision: Two-Level Schema Resolution
|
||||
Schemas resolve from global user data directory, falling back to package built-ins.
|
||||
|
||||
**Resolution order:**
|
||||
1. `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml` - Global user override
|
||||
2. `<package>/schemas/<name>.yaml` - Built-in defaults
|
||||
|
||||
**Rationale:**
|
||||
- Follows XDG Base Directory Specification (schemas are data, not config)
|
||||
- Mirrors existing `getGlobalConfigDir()` pattern in `src/core/global-paths.ts`
|
||||
- Built-ins baked into package, never auto-copied
|
||||
- Users customize by creating files in global data dir
|
||||
- Simple - no project-level overrides (can add later if needed)
|
||||
|
||||
**XDG compliance:**
|
||||
- Uses `XDG_DATA_HOME` env var when set (all platforms)
|
||||
- Unix/macOS fallback: `~/.local/share/openspec/`
|
||||
- Windows fallback: `%LOCALAPPDATA%/openspec/`
|
||||
|
||||
**Alternatives considered:**
|
||||
- Project-level overrides: Added complexity, not needed initially
|
||||
- Auto-copy to user space: Creates drift, harder to update defaults
|
||||
- Config directory (`XDG_CONFIG_HOME`): Schemas are workflow definitions (data), not user preferences (config)
|
||||
|
||||
### Decision: Template Field Parsed But Not Resolved
|
||||
The `template` field is required in schema YAML for completeness, but template resolution is deferred to Slice 3.
|
||||
|
||||
**Rationale:**
|
||||
- Slice 1 focuses on "What's Ready?" - dependency and completion queries only
|
||||
- Template paths are validated syntactically (non-empty string) but not resolved
|
||||
- Keeps Slice 1 focused and independently testable
|
||||
|
||||
### Decision: Cycle Error Format
|
||||
Cycle errors list all artifact IDs in the cycle for easy debugging.
|
||||
|
||||
**Format:** `"Cyclic dependency detected: A → B → C → A"`
|
||||
|
||||
**Rationale:**
|
||||
- Shows the full cycle path, not just that a cycle exists
|
||||
- Actionable - developer can see exactly which artifacts to fix
|
||||
- Consistent with Kahn's algorithm which naturally identifies cycle participants
|
||||
|
||||
## Data Structures
|
||||
|
||||
**Zod Schemas (source of truth):**
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Artifact definition schema
|
||||
export const ArtifactSchema = z.object({
|
||||
id: z.string().min(1, 'Artifact ID is required'),
|
||||
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
|
||||
description: z.string(),
|
||||
template: z.string(), // path to template file
|
||||
requires: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
// Full schema YAML structure
|
||||
export const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1, 'Schema name is required'),
|
||||
version: z.number().int().positive(),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1, 'At least one artifact required'),
|
||||
});
|
||||
|
||||
// Derived TypeScript types
|
||||
export type Artifact = z.infer<typeof ArtifactSchema>;
|
||||
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
|
||||
```
|
||||
|
||||
**Runtime State (not Zod - internal only):**
|
||||
|
||||
```typescript
|
||||
// Slice 1: Simple completion tracking via filesystem
|
||||
type CompletedSet = Set<string>;
|
||||
|
||||
// Return type for blocked query
|
||||
interface BlockedArtifacts {
|
||||
[artifactId: string]: string[]; // artifact → list of unmet dependencies
|
||||
}
|
||||
|
||||
interface ArtifactGraphResult {
|
||||
completed: string[];
|
||||
ready: string[];
|
||||
blocked: BlockedArtifacts;
|
||||
buildOrder: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/core/artifact-graph/
|
||||
├── index.ts # Public exports
|
||||
├── types.ts # Zod schemas and type definitions
|
||||
├── graph.ts # ArtifactGraph class
|
||||
├── state.ts # State detection logic
|
||||
├── resolver.ts # Schema resolution (global → built-in)
|
||||
└── schemas/ # Built-in schema definitions (package level)
|
||||
├── spec-driven.yaml # Default: proposal → specs → design → tasks
|
||||
└── tdd.yaml # Alternative: tests → implementation → docs
|
||||
```
|
||||
|
||||
**Schema Resolution Paths:**
|
||||
- Global user override: `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml`
|
||||
- Package built-in: `src/core/artifact-graph/schemas/<name>.yaml` (bundled with package)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Glob pattern edge cases | Use well-tested glob library (fast-glob or similar) |
|
||||
| Cycle detection | Kahn's algorithm naturally fails on cycles; provide clear error |
|
||||
| Schema evolution | Version field in schema, validate on load |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None - all questions resolved in Decisions section.
|
||||
@@ -1,18 +0,0 @@
|
||||
## Why
|
||||
|
||||
The current OpenSpec system relies on conventions and AI inference for artifact ordering. A formal artifact graph with dependency awareness would enable deterministic "what's ready?" queries, making the system more predictable and enabling future features like automated pipeline execution.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `ArtifactGraph` class to model artifacts as a DAG with dependency relationships
|
||||
- Add `ArtifactState` type to track completion status (completed, in_progress, failed)
|
||||
- Add filesystem-based state detection using file existence and glob patterns
|
||||
- Add schema YAML parser to load artifact definitions
|
||||
- Implement topological sort (Kahn's algorithm) for build order calculation
|
||||
- Add `getNextArtifacts()` to find artifacts ready for creation
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `artifact-graph` capability
|
||||
- Affected code: `src/core/artifact-graph/` (new directory)
|
||||
- No changes to existing functionality - this is a parallel module
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Schema Loading
|
||||
The system SHALL load artifact graph definitions from YAML schema files.
|
||||
|
||||
#### Scenario: Valid schema loaded
|
||||
- **WHEN** a valid schema YAML file is provided
|
||||
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
|
||||
|
||||
#### Scenario: Invalid schema rejected
|
||||
- **WHEN** a schema YAML file is missing required fields
|
||||
- **THEN** the system throws an error with a descriptive message
|
||||
|
||||
#### Scenario: Cyclic dependencies detected
|
||||
- **WHEN** a schema contains cyclic artifact dependencies
|
||||
- **THEN** the system throws an error listing the artifact IDs in the cycle
|
||||
|
||||
#### Scenario: Invalid dependency reference
|
||||
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
|
||||
- **THEN** the system throws an error identifying the invalid reference
|
||||
|
||||
#### Scenario: Duplicate artifact IDs rejected
|
||||
- **WHEN** a schema contains multiple artifacts with the same ID
|
||||
- **THEN** the system throws an error identifying the duplicate
|
||||
|
||||
### Requirement: Build Order Calculation
|
||||
The system SHALL compute a valid topological build order for artifacts.
|
||||
|
||||
#### Scenario: Linear dependency chain
|
||||
- **WHEN** artifacts form a linear chain (A → B → C)
|
||||
- **THEN** getBuildOrder() returns [A, B, C]
|
||||
|
||||
#### Scenario: Diamond dependency
|
||||
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
|
||||
- **THEN** getBuildOrder() returns A before B and C, and D last
|
||||
|
||||
#### Scenario: Independent artifacts
|
||||
- **WHEN** artifacts have no dependencies
|
||||
- **THEN** getBuildOrder() returns them in a stable order
|
||||
|
||||
### Requirement: State Detection
|
||||
The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
|
||||
#### Scenario: Simple file exists
|
||||
- **WHEN** an artifact generates "proposal.md" and the file exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Simple file missing
|
||||
- **WHEN** an artifact generates "proposal.md" and the file does not exist
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Glob pattern with files
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Glob pattern empty
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Missing change directory
|
||||
- **WHEN** the change directory does not exist
|
||||
- **THEN** all artifacts are marked as not completed (empty state)
|
||||
|
||||
### Requirement: Ready Artifact Query
|
||||
The system SHALL identify which artifacts are ready to be created based on dependency completion.
|
||||
|
||||
#### Scenario: Root artifacts ready initially
|
||||
- **WHEN** no artifacts are completed
|
||||
- **THEN** getNextArtifacts() returns artifacts with no dependencies
|
||||
|
||||
#### Scenario: Dependent artifact becomes ready
|
||||
- **WHEN** an artifact's dependencies are all completed
|
||||
- **THEN** getNextArtifacts() includes that artifact
|
||||
|
||||
#### Scenario: Blocked artifacts excluded
|
||||
- **WHEN** an artifact has uncompleted dependencies
|
||||
- **THEN** getNextArtifacts() does not include that artifact
|
||||
|
||||
### Requirement: Completion Check
|
||||
The system SHALL determine when all artifacts in a graph are complete.
|
||||
|
||||
#### Scenario: All complete
|
||||
- **WHEN** all artifacts in the graph are in the completed set
|
||||
- **THEN** isComplete() returns true
|
||||
|
||||
#### Scenario: Partially complete
|
||||
- **WHEN** some artifacts in the graph are not completed
|
||||
- **THEN** isComplete() returns false
|
||||
|
||||
### Requirement: Blocked Query
|
||||
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
|
||||
|
||||
#### Scenario: Artifact blocked by single dependency
|
||||
- **WHEN** artifact B requires artifact A and A is not complete
|
||||
- **THEN** getBlocked() returns `{ B: ['A'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by multiple dependencies
|
||||
- **WHEN** artifact C requires A and B, and only A is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['B'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by all dependencies
|
||||
- **WHEN** artifact C requires A and B, and neither is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
|
||||
@@ -1,61 +0,0 @@
|
||||
## 1. Type Definitions
|
||||
- [x] 1.1 Create `src/core/artifact-graph/types.ts` with Zod schemas (`ArtifactSchema`, `SchemaYamlSchema`) and inferred types via `z.infer<>`
|
||||
- [x] 1.2 Define `CompletedSet` (Set<string>), `BlockedArtifacts`, and `ArtifactGraphResult` types for runtime state
|
||||
|
||||
## 2. Schema Parser
|
||||
- [x] 2.1 Create `src/core/artifact-graph/schema.ts` with YAML loading and Zod validation via `.safeParse()`
|
||||
- [x] 2.2 Implement dependency reference validation (ensure `requires` references valid artifact IDs)
|
||||
- [x] 2.3 Implement duplicate artifact ID detection
|
||||
- [x] 2.4 Add cycle detection during schema load (error format: "Cyclic dependency detected: A → B → C → A")
|
||||
|
||||
## 3. Artifact Graph Core
|
||||
- [x] 3.1 Create `src/core/artifact-graph/graph.ts` with ArtifactGraph class
|
||||
- [x] 3.2 Implement `fromYaml(path)` - load graph from schema file
|
||||
- [x] 3.3 Implement `getBuildOrder()` - topological sort via Kahn's algorithm
|
||||
- [x] 3.4 Implement `getArtifact(id)` - retrieve single artifact definition
|
||||
- [x] 3.5 Implement `getAllArtifacts()` - list all artifacts
|
||||
|
||||
## 4. State Detection
|
||||
- [x] 4.1 Create `src/core/artifact-graph/state.ts` with state detection logic
|
||||
- [x] 4.2 Implement file existence checking for simple paths
|
||||
- [x] 4.3 Implement glob pattern matching for multi-file artifacts
|
||||
- [x] 4.4 Implement `detectCompleted(graph, changeDir)` - scan filesystem and return CompletedSet
|
||||
- [x] 4.5 Handle missing changeDir gracefully (return empty CompletedSet)
|
||||
|
||||
## 5. Ready Calculation
|
||||
- [x] 5.1 Implement `getNextArtifacts(graph, completed)` - find artifacts with all deps completed
|
||||
- [x] 5.2 Implement `isComplete(graph, completed)` - check if all artifacts done
|
||||
- [x] 5.3 Implement `getBlocked(graph, completed)` - return BlockedArtifacts map (artifact → unmet deps)
|
||||
|
||||
## 6. Schema Resolution
|
||||
- [x] 6.1 Create `src/core/artifact-graph/resolver.ts` with schema resolution logic
|
||||
- [x] 6.2 Add `getGlobalDataDir()` to `src/core/global-config.ts` (XDG_DATA_HOME with platform fallbacks)
|
||||
- [x] 6.3 Implement `resolveSchema(name)` - global (`${XDG_DATA_HOME}/openspec/schemas/`) → built-in fallback
|
||||
|
||||
## 7. Built-in Schemas
|
||||
- [x] 7.1 Create `src/core/artifact-graph/schemas/spec-driven.yaml` (default: proposal → specs → design → tasks)
|
||||
- [x] 7.2 Create `src/core/artifact-graph/schemas/tdd.yaml` (alternative: tests → implementation → docs)
|
||||
|
||||
## 8. Integration
|
||||
- [x] 8.1 Create `src/core/artifact-graph/index.ts` with public exports
|
||||
|
||||
## 9. Testing
|
||||
- [x] 9.1 Test: Parse valid schema YAML returns correct artifact graph
|
||||
- [x] 9.2 Test: Parse invalid schema (missing fields) throws descriptive error
|
||||
- [x] 9.3 Test: Duplicate artifact IDs throws error
|
||||
- [x] 9.4 Test: Invalid `requires` reference throws error identifying the invalid ID
|
||||
- [x] 9.5 Test: Cycle in schema throws error listing cycle path (e.g., "A → B → C → A")
|
||||
- [x] 9.6 Test: Compute build order returns correct topological ordering (linear chain)
|
||||
- [x] 9.7 Test: Compute build order handles diamond dependencies correctly
|
||||
- [x] 9.8 Test: Independent artifacts return in stable order
|
||||
- [x] 9.9 Test: Empty/missing changeDir returns empty CompletedSet
|
||||
- [x] 9.10 Test: File existence marks artifact as completed
|
||||
- [x] 9.11 Test: Glob pattern specs/*.md detected as complete when files exist
|
||||
- [x] 9.12 Test: Glob pattern with empty directory not marked complete
|
||||
- [x] 9.13 Test: getNextArtifacts returns only root artifacts when nothing completed
|
||||
- [x] 9.14 Test: getNextArtifacts includes artifact when all deps completed
|
||||
- [x] 9.15 Test: getBlocked returns artifact with all unmet dependencies listed
|
||||
- [x] 9.16 Test: isComplete() returns true when all artifacts completed
|
||||
- [x] 9.17 Test: isComplete() returns false when some artifacts incomplete
|
||||
- [x] 9.18 Test: Schema resolution finds global override before built-in
|
||||
- [x] 9.19 Test: Schema resolution falls back to built-in when no global
|
||||
@@ -1,74 +0,0 @@
|
||||
## Context
|
||||
|
||||
This is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically.
|
||||
|
||||
**Current state:** No programmatic way to create changes. Users must manually create directories.
|
||||
|
||||
**Proposed state:** Utility functions for change creation with name validation.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- **Add** `createChange()` function to create change directories
|
||||
- **Add** `validateChangeName()` function for kebab-case validation
|
||||
- **Enable** automation (Claude commands, scripts) to create changes
|
||||
|
||||
### Non-Goals
|
||||
- Refactor existing CLI commands (they work fine)
|
||||
- Create abstraction layers or manager classes
|
||||
- Change how `ListCommand` or `ChangeCommand` work
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Simple Utility Functions
|
||||
|
||||
**Choice**: Add functions to `src/utils/change-utils.ts` - no class.
|
||||
|
||||
```typescript
|
||||
// src/utils/change-utils.ts
|
||||
|
||||
export function validateChangeName(name: string): { valid: boolean; error?: string }
|
||||
|
||||
export async function createChange(
|
||||
projectRoot: string,
|
||||
name: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**Why**:
|
||||
- Simple, no abstraction overhead
|
||||
- Easy to test
|
||||
- Easy to import where needed
|
||||
- Matches existing utility patterns in `src/utils/`
|
||||
|
||||
**Alternatives considered**:
|
||||
- ChangeManager class: Rejected - over-engineered for 2 functions
|
||||
- Add to existing command: Rejected - mixes CLI with reusable logic
|
||||
|
||||
### Decision 2: Kebab-Case Validation Pattern
|
||||
|
||||
**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
|
||||
|
||||
Valid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor`
|
||||
Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth`
|
||||
|
||||
**Why**:
|
||||
- Filesystem-safe (no special characters)
|
||||
- URL-safe (for future web UI)
|
||||
- Consistent with existing change naming in repo
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
- `src/utils/change-utils.ts` - Utility functions
|
||||
- `src/utils/change-utils.test.ts` - Unit tests
|
||||
|
||||
### Modified Files
|
||||
- None
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Function might not cover all use cases | Start simple, extend if needed |
|
||||
| Naming conflicts with future work | Using clear, specific function names |
|
||||
@@ -1,45 +0,0 @@
|
||||
## Why
|
||||
|
||||
There's no programmatic way to create a new change directory. Users must manually:
|
||||
1. Create `openspec/changes/<name>/` directory
|
||||
2. Create a `proposal.md` file
|
||||
3. Hope they got the naming right
|
||||
|
||||
This is error-prone and blocks automation (e.g., Claude commands, scripts).
|
||||
|
||||
**This proposal adds:**
|
||||
1. `createChange(projectRoot, name)` - Create change directories programmatically
|
||||
2. `validateChangeName(name)` - Enforce kebab-case naming conventions
|
||||
|
||||
## What Changes
|
||||
|
||||
### New Utilities
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `createChange(projectRoot, name)` | Creates `openspec/changes/<name>/` directory |
|
||||
| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` |
|
||||
|
||||
### Name Validation Rules
|
||||
|
||||
Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
|
||||
|
||||
| Valid | Invalid |
|
||||
|-------|---------|
|
||||
| `add-auth` | `Add-Auth` (uppercase) |
|
||||
| `refactor-db` | `add auth` (spaces) |
|
||||
| `add-feature-2` | `add_auth` (underscores) |
|
||||
| `refactor` | `-add-auth` (leading hyphen) |
|
||||
|
||||
### Location
|
||||
|
||||
New file: `src/utils/change-utils.ts`
|
||||
|
||||
Simple utility functions - no class, no abstraction layer.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected specs**: None
|
||||
- **Affected code**: None (new utilities only)
|
||||
- **New files**: `src/utils/change-utils.ts`
|
||||
- **Breaking changes**: None
|
||||
@@ -1,63 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Change Creation
|
||||
The system SHALL provide a function to create new change directories programmatically.
|
||||
|
||||
#### Scenario: Create change
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
|
||||
- **THEN** the system creates `openspec/changes/add-auth/` directory
|
||||
|
||||
#### Scenario: Duplicate change rejected
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
|
||||
- **THEN** the system throws an error indicating the change already exists
|
||||
|
||||
#### Scenario: Creates parent directories if needed
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
|
||||
- **THEN** the system creates the full path including parent directories
|
||||
|
||||
#### Scenario: Invalid change name rejected
|
||||
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
|
||||
- **THEN** the system throws a validation error
|
||||
|
||||
### Requirement: Change Name Validation
|
||||
The system SHALL validate change names follow kebab-case conventions.
|
||||
|
||||
#### Scenario: Valid kebab-case name accepted
|
||||
- **WHEN** a change name like `add-user-auth` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Numeric suffixes accepted
|
||||
- **WHEN** a change name like `add-feature-2` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Single word accepted
|
||||
- **WHEN** a change name like `refactor` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Uppercase characters rejected
|
||||
- **WHEN** a change name like `Add-Auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Spaces rejected
|
||||
- **WHEN** a change name like `add auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Underscores rejected
|
||||
- **WHEN** a change name like `add_auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Special characters rejected
|
||||
- **WHEN** a change name like `add-auth!` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Leading hyphen rejected
|
||||
- **WHEN** a change name like `-add-auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Trailing hyphen rejected
|
||||
- **WHEN** a change name like `add-auth-` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Consecutive hyphens rejected
|
||||
- **WHEN** a change name like `add--auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
@@ -1,30 +0,0 @@
|
||||
## Phase 1: Implement Name Validation
|
||||
|
||||
- [x] 1.1 Create `src/utils/change-utils.ts`
|
||||
- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern
|
||||
- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
|
||||
- [x] 1.4 Return `{ valid: boolean; error?: string }`
|
||||
- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)
|
||||
- [x] 1.6 Add test: uppercase rejected
|
||||
- [x] 1.7 Add test: spaces rejected
|
||||
- [x] 1.8 Add test: underscores rejected
|
||||
- [x] 1.9 Add test: special characters rejected
|
||||
- [x] 1.10 Add test: leading/trailing hyphens rejected
|
||||
- [x] 1.11 Add test: consecutive hyphens rejected
|
||||
|
||||
## Phase 2: Implement Change Creation
|
||||
|
||||
- [x] 2.1 Implement `createChange(projectRoot, name)`
|
||||
- [x] 2.2 Validate name before creating
|
||||
- [x] 2.3 Create parent directories if needed (`openspec/changes/`)
|
||||
- [x] 2.4 Throw if change already exists
|
||||
- [x] 2.5 Add test: creates directory
|
||||
- [x] 2.6 Add test: duplicate change throws error
|
||||
- [x] 2.7 Add test: invalid name throws validation error
|
||||
- [x] 2.8 Add test: creates parent directories if needed
|
||||
|
||||
## Phase 3: Integration
|
||||
|
||||
- [x] 3.1 Export functions from `src/utils/index.ts`
|
||||
- [x] 3.2 Add JSDoc comments
|
||||
- [x] 3.3 Run all tests to verify no regressions
|
||||
@@ -1,112 +0,0 @@
|
||||
## Context
|
||||
|
||||
Slice 4 of the artifact workflow POC. The core functionality (ArtifactGraph, InstructionLoader, change-utils) is complete. This slice adds CLI commands to expose the artifact workflow to users.
|
||||
|
||||
**Key constraint**: This is experimental. Commands must be isolated for easy removal if the feature doesn't work out.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- **Goals:**
|
||||
- Expose artifact workflow status and instructions via CLI
|
||||
- Provide fluid UX with top-level verb commands
|
||||
- Support both human-readable and JSON output
|
||||
- Enable agents to programmatically query workflow state
|
||||
- Keep implementation isolated for easy removal
|
||||
|
||||
- **Non-Goals:**
|
||||
- Interactive artifact creation wizards (future work)
|
||||
- Schema management commands (deferred)
|
||||
- Auto-detection of active change (CLI is deterministic, agents infer)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Command Structure: Top-Level Verbs
|
||||
|
||||
Commands are top-level for maximum fluidity:
|
||||
|
||||
```
|
||||
openspec status --change <id>
|
||||
openspec next --change <id>
|
||||
openspec instructions <artifact> --change <id>
|
||||
openspec templates [--schema <name>]
|
||||
openspec new change <name>
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Most fluid UX - fewest keystrokes
|
||||
- Commands are unique enough to avoid conflicts
|
||||
- Simple mental model for users
|
||||
|
||||
**Trade-off accepted:** Slight namespace pollution, but commands are distinct and can be removed cleanly.
|
||||
|
||||
### Experimental Isolation
|
||||
|
||||
All artifact workflow commands are implemented in a single file:
|
||||
|
||||
```
|
||||
src/commands/artifact-workflow.ts
|
||||
```
|
||||
|
||||
**To remove the feature:**
|
||||
1. Delete `src/commands/artifact-workflow.ts`
|
||||
2. Remove ~5 lines from `src/cli/index.ts`
|
||||
|
||||
No other files touched, no risk to stable functionality.
|
||||
|
||||
### Deterministic CLI with Explicit `--change`
|
||||
|
||||
All change-specific commands require `--change <id>`:
|
||||
|
||||
```bash
|
||||
openspec status --change add-auth # explicit, works
|
||||
openspec status # error: missing --change
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- CLI is pure, testable, no hidden state
|
||||
- Agents infer change from conversation and pass explicitly
|
||||
- No config file tracking "active change"
|
||||
- Consistent with POC design philosophy
|
||||
|
||||
### New Change Command Structure
|
||||
|
||||
Creating changes uses explicit subcommand:
|
||||
|
||||
```bash
|
||||
openspec new change add-feature
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- `openspec new <name>` is ambiguous (new what?)
|
||||
- `openspec new change <name>` is clear and extensible
|
||||
- Can add `openspec new spec <name>` later if needed
|
||||
|
||||
### Output Formats
|
||||
|
||||
- **Default**: Human-readable text with visual indicators
|
||||
- Status: `[x]` done, `[ ]` ready, `[-]` blocked
|
||||
- Colors: green (done), yellow (ready), red (blocked)
|
||||
- **JSON** (`--json`): Machine-readable for scripts and agents
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Missing `--change`: Error listing available changes
|
||||
- Unknown change: Error with suggestion
|
||||
- Unknown artifact: Error listing valid artifacts
|
||||
- Missing schema: Error with schema resolution details
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Top-level commands pollute namespace | Commands are distinct; isolated for easy removal |
|
||||
| `status` confused with git | Context (`--change`) makes it clear |
|
||||
| Feature doesn't work out | Single file deletion removes everything |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All commands in `src/commands/artifact-workflow.ts`
|
||||
- Imports from `src/core/artifact-graph/` for all operations
|
||||
- Uses `getActiveChangeIds()` from `item-discovery.ts` for change listing
|
||||
- Follows existing CLI patterns (ora spinners, commander.js options)
|
||||
- Help text marks commands as "Experimental"
|
||||
@@ -1,33 +0,0 @@
|
||||
## Why
|
||||
|
||||
The ArtifactGraph (Slice 1) and InstructionLoader (Slice 3) provide programmatic APIs for artifact-based workflow management. Users currently have no CLI interface to:
|
||||
- See artifact completion status for a change
|
||||
- Discover what artifacts are ready to create
|
||||
- Get enriched instructions for creating artifacts
|
||||
- Create new changes with proper validation
|
||||
|
||||
This proposal adds CLI commands that expose the artifact workflow functionality to users and agents.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **NEW**: `openspec status --change <id>` shows artifact completion state
|
||||
- **NEW**: `openspec next --change <id>` shows artifacts ready to create
|
||||
- **NEW**: `openspec instructions <artifact> --change <id>` outputs enriched template
|
||||
- **NEW**: `openspec templates [--schema <name>]` shows resolved template paths
|
||||
- **NEW**: `openspec new change <name>` creates a new change directory
|
||||
|
||||
All commands are top-level for fluid UX. They integrate with existing core modules:
|
||||
- Uses `loadChangeContext()`, `formatChangeStatus()`, `generateInstructions()` from instruction-loader
|
||||
- Uses `ArtifactGraph`, `detectCompleted()` from artifact-graph
|
||||
- Uses `createChange()`, `validateChangeName()` from change-utils
|
||||
|
||||
**Experimental isolation**: All commands are implemented in a single file (`src/commands/artifact-workflow.ts`) for easy removal if the feature doesn't work out. Help text marks them as experimental.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: NEW `cli-artifact-workflow` capability
|
||||
- Affected code:
|
||||
- `src/cli/index.ts` - register new commands
|
||||
- `src/commands/artifact-workflow.ts` - new command implementations
|
||||
- No changes to existing commands or specs
|
||||
- Builds on completed Slice 1, 2, and 3 implementations
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
# cli-artifact-workflow Specification
|
||||
|
||||
## Purpose
|
||||
CLI commands for artifact workflow operations, exposing the artifact graph and instruction loader functionality to users and agents. Commands are top-level for fluid UX and implemented in isolation for easy removal.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Status Command
|
||||
The system SHALL display artifact completion status for a change.
|
||||
|
||||
#### Scenario: Show status with all states
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** the system displays each artifact with status indicator:
|
||||
- `[x]` for completed artifacts
|
||||
- `[ ]` for ready artifacts
|
||||
- `[-]` for blocked artifacts (with missing dependencies listed)
|
||||
|
||||
#### Scenario: Status shows completion summary
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
|
||||
|
||||
#### Scenario: Status JSON output
|
||||
- **WHEN** user runs `openspec status --change <id> --json`
|
||||
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
|
||||
|
||||
#### Scenario: Missing change parameter
|
||||
- **WHEN** user runs `openspec status` without `--change`
|
||||
- **THEN** the system displays an error with list of available changes
|
||||
|
||||
#### Scenario: Unknown change
|
||||
- **WHEN** user runs `openspec status --change unknown-id`
|
||||
- **THEN** the system displays an error indicating the change does not exist
|
||||
|
||||
### Requirement: Next Command
|
||||
The system SHALL show which artifacts are ready to be created.
|
||||
|
||||
#### Scenario: Show ready artifacts
|
||||
- **WHEN** user runs `openspec next --change <id>`
|
||||
- **THEN** the system lists artifacts whose dependencies are all satisfied
|
||||
|
||||
#### Scenario: No artifacts ready
|
||||
- **WHEN** all artifacts are either completed or blocked
|
||||
- **THEN** the system indicates no artifacts are ready (with explanation)
|
||||
|
||||
#### Scenario: All artifacts complete
|
||||
- **WHEN** all artifacts in the change are completed
|
||||
- **THEN** the system indicates the change is complete
|
||||
|
||||
#### Scenario: Next JSON output
|
||||
- **WHEN** user runs `openspec next --change <id> --json`
|
||||
- **THEN** the system outputs JSON array of ready artifact IDs
|
||||
|
||||
### Requirement: Instructions Command
|
||||
The system SHALL output enriched instructions for creating an artifact.
|
||||
|
||||
#### Scenario: Show enriched instructions
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
|
||||
- **THEN** the system outputs:
|
||||
- Artifact metadata (ID, output path, description)
|
||||
- Template content
|
||||
- Dependency status (done/missing)
|
||||
- Unlocked artifacts (what becomes available after completion)
|
||||
|
||||
#### Scenario: Instructions JSON output
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
|
||||
- **THEN** the system outputs JSON matching ArtifactInstructions interface
|
||||
|
||||
#### Scenario: Unknown artifact
|
||||
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
|
||||
- **THEN** the system displays an error listing valid artifact IDs for the schema
|
||||
|
||||
#### Scenario: Artifact with unmet dependencies
|
||||
- **WHEN** user requests instructions for a blocked artifact
|
||||
- **THEN** the system displays instructions with a warning about missing dependencies
|
||||
|
||||
### Requirement: Templates Command
|
||||
The system SHALL show resolved template paths for all artifacts in a schema.
|
||||
|
||||
#### Scenario: List template paths with default schema
|
||||
- **WHEN** user runs `openspec templates`
|
||||
- **THEN** the system displays each artifact with its resolved template path using the default schema
|
||||
|
||||
#### Scenario: List template paths with custom schema
|
||||
- **WHEN** user runs `openspec templates --schema tdd`
|
||||
- **THEN** the system displays template paths for the specified schema
|
||||
|
||||
#### Scenario: Templates JSON output
|
||||
- **WHEN** user runs `openspec templates --json`
|
||||
- **THEN** the system outputs JSON mapping artifact IDs to template paths
|
||||
|
||||
#### Scenario: Template resolution source
|
||||
- **WHEN** displaying template paths
|
||||
- **THEN** the system indicates whether each template is from user override or package built-in
|
||||
|
||||
### Requirement: New Change Command
|
||||
The system SHALL create new change directories with validation.
|
||||
|
||||
#### Scenario: Create valid change
|
||||
- **WHEN** user runs `openspec new change add-feature`
|
||||
- **THEN** the system creates `openspec/changes/add-feature/` directory
|
||||
|
||||
#### Scenario: Invalid change name
|
||||
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
|
||||
- **THEN** the system displays validation error with guidance
|
||||
|
||||
#### Scenario: Duplicate change name
|
||||
- **WHEN** user runs `openspec new change existing-change` for an existing change
|
||||
- **THEN** the system displays an error indicating the change already exists
|
||||
|
||||
#### Scenario: Create with description
|
||||
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
|
||||
- **THEN** the system creates the change directory with description in README.md
|
||||
|
||||
### Requirement: Schema Selection
|
||||
The system SHALL support custom schema selection for workflow commands.
|
||||
|
||||
#### Scenario: Default schema
|
||||
- **WHEN** user runs workflow commands without `--schema`
|
||||
- **THEN** the system uses the "spec-driven" schema
|
||||
|
||||
#### Scenario: Custom schema
|
||||
- **WHEN** user runs `openspec status --change <id> --schema tdd`
|
||||
- **THEN** the system uses the specified schema for artifact graph
|
||||
|
||||
#### Scenario: Unknown schema
|
||||
- **WHEN** user specifies an unknown schema
|
||||
- **THEN** the system displays an error listing available schemas
|
||||
|
||||
### Requirement: Output Formatting
|
||||
The system SHALL provide consistent output formatting.
|
||||
|
||||
#### Scenario: Color output
|
||||
- **WHEN** terminal supports colors
|
||||
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
|
||||
|
||||
#### Scenario: No color output
|
||||
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
|
||||
- **THEN** output uses text-only indicators without ANSI colors
|
||||
|
||||
#### Scenario: Progress indication
|
||||
- **WHEN** loading change state takes time
|
||||
- **THEN** the system displays a spinner during loading
|
||||
|
||||
### Requirement: Experimental Isolation
|
||||
The system SHALL implement artifact workflow commands in isolation for easy removal.
|
||||
|
||||
#### Scenario: Single file implementation
|
||||
- **WHEN** artifact workflow feature is implemented
|
||||
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
|
||||
|
||||
#### Scenario: Help text marking
|
||||
- **WHEN** user runs `--help` on any artifact workflow command
|
||||
- **THEN** help text indicates the command is experimental
|
||||
@@ -1,48 +0,0 @@
|
||||
## 1. Core Command Implementation
|
||||
|
||||
- [x] 1.1 Create `src/commands/artifact-workflow.ts` with all commands
|
||||
- [x] 1.2 Implement `status` command with text output
|
||||
- [x] 1.3 Implement `next` command with text output
|
||||
- [x] 1.4 Implement `instructions` command with text output
|
||||
- [x] 1.5 Implement `templates` command with text output
|
||||
- [x] 1.6 Implement `new change` subcommand using createChange()
|
||||
|
||||
## 2. CLI Registration
|
||||
|
||||
- [x] 2.1 Register `status` command in `src/cli/index.ts`
|
||||
- [x] 2.2 Register `next` command in `src/cli/index.ts`
|
||||
- [x] 2.3 Register `instructions` command in `src/cli/index.ts`
|
||||
- [x] 2.4 Register `templates` command in `src/cli/index.ts`
|
||||
- [x] 2.5 Register `new` command group with `change` subcommand
|
||||
|
||||
## 3. Output Formatting
|
||||
|
||||
- [x] 3.1 Add `--json` flag support to all commands
|
||||
- [x] 3.2 Add color-coded status indicators (done/ready/blocked)
|
||||
- [x] 3.3 Add progress spinner for loading operations
|
||||
- [x] 3.4 Support `--no-color` flag
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
- [x] 4.1 Handle missing `--change` parameter with helpful error
|
||||
- [x] 4.2 Handle unknown change names with list of available changes
|
||||
- [x] 4.3 Handle unknown artifact names with valid options
|
||||
- [x] 4.4 Handle schema resolution errors
|
||||
|
||||
## 5. Options and Flags
|
||||
|
||||
- [x] 5.1 Add `--schema` option for custom schema selection
|
||||
- [x] 5.2 Add `--description` option to `new change` command
|
||||
- [x] 5.3 Ensure options follow existing CLI patterns
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- [x] 6.1 Add smoke tests for each command
|
||||
- [x] 6.2 Test error cases (missing change, unknown artifact)
|
||||
- [x] 6.3 Test JSON output format
|
||||
- [x] 6.4 Test with different schemas
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [x] 7.1 Add help text for all commands marked as "Experimental"
|
||||
- [ ] 7.2 Update AGENTS.md with new commands (post-archive)
|
||||
@@ -1,149 +0,0 @@
|
||||
## Context
|
||||
|
||||
This is Slice 3 of the artifact-graph POC. We have:
|
||||
- `ArtifactGraph` class with graph operations (Slice 1)
|
||||
- `detectCompleted()` for filesystem-based state detection (Slice 1)
|
||||
- `resolveSchema()` for XDG schema resolution (Slice 1)
|
||||
- `createChange()` and `validateChangeName()` utilities (Slice 2)
|
||||
|
||||
After `restructure-schema-directories` is implemented, schemas will be self-contained directories:
|
||||
```
|
||||
schemas/<name>/
|
||||
├── schema.yaml
|
||||
└── templates/
|
||||
└── *.md
|
||||
```
|
||||
|
||||
This proposal adds template loading and instruction enrichment on top of that structure.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Load templates from schema directories
|
||||
- Enrich templates with change-specific context (dependency status)
|
||||
- Format change status for CLI output
|
||||
|
||||
**Non-Goals:**
|
||||
- Template authoring UI
|
||||
- Dynamic template compilation/execution
|
||||
- Caching (keep it stateless like the rest)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Pure functions over classes
|
||||
|
||||
Follow the pattern in `resolver.ts` and `state.ts`. Use a simple `ChangeContext` interface with pure functions:
|
||||
|
||||
```typescript
|
||||
interface ChangeContext {
|
||||
changeName: string;
|
||||
changeDir: string;
|
||||
schemaName: string;
|
||||
graph: ArtifactGraph;
|
||||
completed: CompletedSet;
|
||||
}
|
||||
|
||||
function loadChangeContext(projectRoot: string, changeName: string, schemaName?: string): ChangeContext
|
||||
function loadTemplate(schemaName: string, templatePath: string): string
|
||||
function getInstructions(artifactId: string, context: ChangeContext): string
|
||||
function formatStatus(context: ChangeContext): string
|
||||
```
|
||||
|
||||
**Why:** Matches existing codebase patterns. Easier to test. No hidden state.
|
||||
|
||||
### 2. Template resolution from schema directory
|
||||
|
||||
Templates are loaded from the schema's `templates/` subdirectory:
|
||||
|
||||
```typescript
|
||||
function loadTemplate(schemaName: string, templatePath: string): string {
|
||||
const schemaDir = getSchemaDir(schemaName); // From resolver.ts
|
||||
const fullPath = path.join(schemaDir, 'templates', templatePath);
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
}
|
||||
```
|
||||
|
||||
Resolution is handled by `getSchemaDir()` which already checks user override → package built-in.
|
||||
|
||||
**Why:** Leverages existing schema resolution. Templates are co-located with schemas.
|
||||
|
||||
### 3. Template path from artifact definition
|
||||
|
||||
The artifact's `template` field is a path relative to the schema's `templates/` directory:
|
||||
|
||||
```yaml
|
||||
artifacts:
|
||||
- id: proposal
|
||||
template: "proposal.md" # → schemas/<schema>/templates/proposal.md
|
||||
```
|
||||
|
||||
**Why:** Explicit, simple, no magic.
|
||||
|
||||
### 4. Minimal context injection
|
||||
|
||||
Templates are markdown. Injection prepends a header section with context:
|
||||
|
||||
```markdown
|
||||
---
|
||||
change: add-auth
|
||||
artifact: proposal
|
||||
schema: spec-driven
|
||||
output: openspec/changes/add-auth/proposal.md
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
- [x] (none - this is a root artifact)
|
||||
|
||||
## Next Steps
|
||||
After creating this artifact, you can work on: design, specs
|
||||
|
||||
---
|
||||
|
||||
[original template content...]
|
||||
```
|
||||
|
||||
**Why:** Simple string concatenation. No template engine dependency. Clear separation.
|
||||
|
||||
### 5. Status output format
|
||||
|
||||
```markdown
|
||||
## Change: add-auth (spec-driven)
|
||||
|
||||
| Artifact | Status | Output |
|
||||
|----------|--------|--------|
|
||||
| proposal | done | proposal.md |
|
||||
| specs | ready | specs/*.md |
|
||||
| design | blocked (needs: proposal) | design.md |
|
||||
| tasks | blocked (needs: specs, design) | tasks.md |
|
||||
```
|
||||
|
||||
**Why:** Markdown table is readable in terminal and docs. Matches CLI output style.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/core/artifact-graph/
|
||||
├── index.ts # Add new exports
|
||||
├── template.ts # NEW: Template loading
|
||||
├── context.ts # NEW: ChangeContext loading
|
||||
└── instructions.ts # NEW: Enrichment and formatting
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**Dependency on restructure-schema-directories:**
|
||||
- This proposal requires the schema restructure to be done first
|
||||
- Mitigation: Clear dependency documented, implement in order
|
||||
|
||||
**No template engine:**
|
||||
- Pro: Zero dependencies, simple code
|
||||
- Con: Limited expressiveness
|
||||
- Mitigation: Current use case only needs static templates + header injection
|
||||
|
||||
## Migration Plan
|
||||
|
||||
N/A - new capability, no existing code to migrate.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -1,20 +0,0 @@
|
||||
## Why
|
||||
|
||||
Slice 1 (artifact-graph) provides graph operations and state detection. Slice 2 (change-utils) provides change creation. We now need the ability to load templates for artifacts and enrich them with change-specific context so users/agents know what to create next.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add template resolution from schema directories (uses structure from `restructure-schema-directories`)
|
||||
- Add instruction enrichment that injects change context into templates
|
||||
- Add status formatting for CLI output
|
||||
- New `instruction-loader` capability
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires `restructure-schema-directories` to be implemented first (schemas as directories with co-located templates)
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `instruction-loader` spec
|
||||
- Affected code: `src/core/artifact-graph/` (new files)
|
||||
- Builds on: `artifact-graph` (Slice 1), uses `ArtifactGraph`, `detectCompleted`, `resolveSchema`
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
# instruction-loader Specification
|
||||
|
||||
## Purpose
|
||||
Load templates from schema directories and enrich them with change-specific context for guiding artifact creation.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Template Loading
|
||||
The system SHALL load templates from schema directories.
|
||||
|
||||
#### Scenario: Load template from schema directory
|
||||
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
|
||||
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
|
||||
|
||||
#### Scenario: Template file not found
|
||||
- **WHEN** a template file does not exist in the schema's templates directory
|
||||
- **THEN** the system throws an error with the template path
|
||||
|
||||
### Requirement: Change Context Loading
|
||||
The system SHALL load change context combining graph and completion state.
|
||||
|
||||
#### Scenario: Load context for existing change
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change
|
||||
- **THEN** the system returns a context with graph, completed set, schema name, and change info
|
||||
|
||||
#### Scenario: Load context with custom schema
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called
|
||||
- **THEN** the system uses the specified schema instead of default
|
||||
|
||||
#### Scenario: Load context for non-existent change directory
|
||||
- **WHEN** `loadChangeContext` is called for a non-existent change directory
|
||||
- **THEN** the system returns context with empty completed set
|
||||
|
||||
### Requirement: Template Enrichment
|
||||
The system SHALL enrich templates with change-specific context.
|
||||
|
||||
#### Scenario: Include artifact metadata
|
||||
- **WHEN** instructions are generated for an artifact
|
||||
- **THEN** the output includes change name, artifact ID, schema name, and output path
|
||||
|
||||
#### Scenario: Include dependency status
|
||||
- **WHEN** an artifact has dependencies
|
||||
- **THEN** the output shows each dependency with completion status (done/missing)
|
||||
|
||||
#### Scenario: Include unlocked artifacts
|
||||
- **WHEN** instructions are generated
|
||||
- **THEN** the output includes which artifacts become available after this one
|
||||
|
||||
#### Scenario: Root artifact indicator
|
||||
- **WHEN** an artifact has no dependencies
|
||||
- **THEN** the dependency section indicates this is a root artifact
|
||||
|
||||
### Requirement: Status Formatting
|
||||
The system SHALL format change status as readable output.
|
||||
|
||||
#### Scenario: All artifacts completed
|
||||
- **WHEN** all artifacts are completed
|
||||
- **THEN** status shows all artifacts as "done"
|
||||
|
||||
#### Scenario: Mixed completion status
|
||||
- **WHEN** some artifacts are completed
|
||||
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
|
||||
|
||||
#### Scenario: Blocked artifact details
|
||||
- **WHEN** an artifact is blocked
|
||||
- **THEN** status shows which dependencies are missing
|
||||
|
||||
#### Scenario: Include output paths
|
||||
- **WHEN** status is formatted
|
||||
- **THEN** each artifact shows its output path pattern
|
||||
@@ -1,13 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
- [x] Create `instruction-loader` spec in `openspec/specs/instruction-loader/spec.md`
|
||||
- [x] Implement `loadTemplate` function to load templates from schema directories
|
||||
- [x] Implement `loadChangeContext` function to combine graph and completion state
|
||||
- [x] Implement `generateInstructions` function to enrich templates with change context
|
||||
- [x] Implement `formatChangeStatus` function for readable status output
|
||||
- [x] Export new functions from `src/core/artifact-graph/index.ts`
|
||||
- [x] Add comprehensive tests in `test/core/artifact-graph/instruction-loader.test.ts`
|
||||
- [x] Verify build passes
|
||||
- [x] Verify all tests pass
|
||||
@@ -1,129 +0,0 @@
|
||||
## Context
|
||||
|
||||
Built-in schemas are currently embedded as TypeScript objects:
|
||||
|
||||
```typescript
|
||||
// src/core/artifact-graph/builtin-schemas.ts
|
||||
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
|
||||
name: 'spec-driven',
|
||||
version: 1,
|
||||
artifacts: [...]
|
||||
};
|
||||
```
|
||||
|
||||
This doesn't support templates co-located with schemas. The instruction loader (Slice 3) needs templates, and the cleanest approach is self-contained schema directories.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Schemas as self-contained directories (schema.yaml + templates/)
|
||||
- User overrides via XDG data directory
|
||||
- Simple 2-level resolution (user → package)
|
||||
- Templates co-located with their schema
|
||||
|
||||
**Non-Goals:**
|
||||
- Shared template fallback (intentionally avoiding complexity)
|
||||
- Runtime schema compilation
|
||||
- Schema inheritance
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Directory structure
|
||||
|
||||
Each schema is a directory containing `schema.yaml` and `templates/`:
|
||||
|
||||
```
|
||||
<package>/schemas/
|
||||
├── spec-driven/
|
||||
│ ├── schema.yaml
|
||||
│ └── templates/
|
||||
│ ├── proposal.md
|
||||
│ ├── design.md
|
||||
│ ├── spec.md
|
||||
│ └── tasks.md
|
||||
└── tdd/
|
||||
├── schema.yaml
|
||||
└── templates/
|
||||
├── spec.md
|
||||
├── test.md
|
||||
├── implementation.md
|
||||
└── docs.md
|
||||
```
|
||||
|
||||
**Why:** Self-contained like Helm charts. No cross-schema dependencies. Each schema owns its templates.
|
||||
|
||||
### 2. Resolution order (2 levels)
|
||||
|
||||
```
|
||||
1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
|
||||
2. <package>/schemas/<name>/schema.yaml # Built-in
|
||||
3. Error (not found)
|
||||
```
|
||||
|
||||
**Why:** Simple mental model. User can override entire schema directory or just parts.
|
||||
|
||||
### 3. Template path in schema.yaml
|
||||
|
||||
The `template` field is relative to the schema's `templates/` directory:
|
||||
|
||||
```yaml
|
||||
# schemas/spec-driven/schema.yaml
|
||||
artifacts:
|
||||
- id: proposal
|
||||
template: "proposal.md" # → schemas/spec-driven/templates/proposal.md
|
||||
```
|
||||
|
||||
**Why:** Paths are relative to the schema, not a global templates directory.
|
||||
|
||||
### 4. Resolve package directory via import.meta.url
|
||||
|
||||
```typescript
|
||||
function getPackageSchemasDir(): string {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
// Navigate from src/core/artifact-graph/ to package root
|
||||
return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Works in ESM. No hardcoded paths.
|
||||
|
||||
### 5. Keep schema.yaml format unchanged
|
||||
|
||||
The YAML format stays the same - only the storage location changes:
|
||||
|
||||
```yaml
|
||||
name: spec-driven
|
||||
version: 1
|
||||
description: Specification-driven development
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: "proposal.md"
|
||||
template: "proposal.md"
|
||||
requires: []
|
||||
```
|
||||
|
||||
**Why:** No breaking changes to schema format. Just moving from TS to YAML files.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Create `schemas/` directory at package root
|
||||
2. Convert `SPEC_DRIVEN_SCHEMA` to `schemas/spec-driven/schema.yaml`
|
||||
3. Convert `TDD_SCHEMA` to `schemas/tdd/schema.yaml`
|
||||
4. Update `resolveSchema()` to load from directories
|
||||
5. Remove `builtin-schemas.ts`
|
||||
6. Update `listSchemas()` to scan directories
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**File I/O at runtime:**
|
||||
- Previously schemas were in-memory objects
|
||||
- Now requires reading YAML files
|
||||
- Mitigation: Schemas are small, loaded once per operation
|
||||
|
||||
**Package distribution:**
|
||||
- Must ensure `schemas/` directory is included in npm package
|
||||
- Add to `files` in package.json
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -1,20 +0,0 @@
|
||||
## Why
|
||||
|
||||
Currently, built-in schemas are embedded as TypeScript objects in `builtin-schemas.ts`. This works for schemas but doesn't support co-located templates. To enable self-contained schema packages (schema + templates together), we need to restructure schemas as directories.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING (internal):** Move built-in schemas from embedded TS objects to actual directory structure
|
||||
- Schemas become directories containing `schema.yaml` + `templates/`
|
||||
- Update `resolveSchema()` to load from directory structure
|
||||
- Remove `builtin-schemas.ts` (replaced by file-based schemas)
|
||||
- Update resolution to check user dir → package dir
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `artifact-graph` (schema resolution changes)
|
||||
- Affected code:
|
||||
- Remove `src/core/artifact-graph/builtin-schemas.ts`
|
||||
- Update `src/core/artifact-graph/resolver.ts`
|
||||
- Add `schemas/` directory at package root
|
||||
- No external API changes (resolution still returns `SchemaYaml`)
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Schema Loading
|
||||
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
|
||||
|
||||
#### Scenario: Valid schema loaded
|
||||
- **WHEN** a schema directory contains a valid `schema.yaml` file
|
||||
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
|
||||
|
||||
#### Scenario: Invalid schema rejected
|
||||
- **WHEN** a schema YAML file is missing required fields
|
||||
- **THEN** the system throws an error with a descriptive message
|
||||
|
||||
#### Scenario: Cyclic dependencies detected
|
||||
- **WHEN** a schema contains cyclic artifact dependencies
|
||||
- **THEN** the system throws an error listing the artifact IDs in the cycle
|
||||
|
||||
#### Scenario: Invalid dependency reference
|
||||
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
|
||||
- **THEN** the system throws an error identifying the invalid reference
|
||||
|
||||
#### Scenario: Duplicate artifact IDs rejected
|
||||
- **WHEN** a schema contains multiple artifacts with the same ID
|
||||
- **THEN** the system throws an error identifying the duplicate
|
||||
|
||||
#### Scenario: Schema directory not found
|
||||
- **WHEN** resolving a schema name that has no corresponding directory
|
||||
- **THEN** the system throws an error listing available schemas
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Schema Directory Structure
|
||||
The system SHALL support self-contained schema directories with co-located templates.
|
||||
|
||||
#### Scenario: Schema with templates
|
||||
- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory
|
||||
- **THEN** artifacts can reference templates relative to the schema's templates directory
|
||||
|
||||
#### Scenario: User schema override
|
||||
- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`
|
||||
- **THEN** the system uses that directory instead of the built-in
|
||||
|
||||
#### Scenario: Built-in schema fallback
|
||||
- **WHEN** no user override exists for a schema
|
||||
- **THEN** the system uses the package built-in schema directory
|
||||
|
||||
#### Scenario: List available schemas
|
||||
- **WHEN** listing schemas
|
||||
- **THEN** the system returns schema names from both user and package directories
|
||||
@@ -1,32 +0,0 @@
|
||||
## 1. Create Schema Directories
|
||||
|
||||
- [ ] 1.1 Create `schemas/` directory at package root
|
||||
- [ ] 1.2 Create `schemas/spec-driven/schema.yaml` from `SPEC_DRIVEN_SCHEMA`
|
||||
- [ ] 1.3 Create `schemas/spec-driven/templates/` with placeholder templates
|
||||
- [ ] 1.4 Create `schemas/tdd/schema.yaml` from `TDD_SCHEMA`
|
||||
- [ ] 1.5 Create `schemas/tdd/templates/` with placeholder templates
|
||||
|
||||
## 2. Update Schema Resolution
|
||||
|
||||
- [ ] 2.1 Add `getPackageSchemasDir()` function using `import.meta.url`
|
||||
- [ ] 2.2 Add `getSchemaDir(name)` to resolve schema directory path
|
||||
- [ ] 2.3 Update `resolveSchema()` to load from directory structure
|
||||
- [ ] 2.4 Update `listSchemas()` to scan directories instead of object keys
|
||||
- [ ] 2.5 Add tests for user override resolution
|
||||
- [ ] 2.6 Add tests for built-in fallback
|
||||
|
||||
## 3. Cleanup
|
||||
|
||||
- [ ] 3.1 Remove `builtin-schemas.ts`
|
||||
- [ ] 3.2 Update `index.ts` exports (remove `BUILTIN_SCHEMAS`, `SPEC_DRIVEN_SCHEMA`, `TDD_SCHEMA`)
|
||||
- [ ] 3.3 Update any code that imports removed exports
|
||||
|
||||
## 4. Package Distribution
|
||||
|
||||
- [ ] 4.1 Add `schemas/` to `files` array in `package.json`
|
||||
- [ ] 4.2 Verify schemas are included in built package
|
||||
|
||||
## 5. Fix Template Paths
|
||||
|
||||
- [ ] 5.1 Update `template` field in schema.yaml files (remove `templates/` prefix)
|
||||
- [ ] 5.2 Ensure template paths are relative to schema's templates directory
|
||||
@@ -1,151 +0,0 @@
|
||||
# Design: Unify Change State Model
|
||||
|
||||
## Overview
|
||||
|
||||
This change fixes two bugs with minimal disruption to the existing system:
|
||||
|
||||
1. **View bug**: Empty changes incorrectly shown as "Completed"
|
||||
2. **Artifact workflow bug**: Commands fail on scaffolded changes
|
||||
|
||||
## Key Design Decision: Two Systems, Two Purposes
|
||||
|
||||
The task-based and artifact-based systems serve **different purposes** and should coexist:
|
||||
|
||||
| System | Purpose | Used By |
|
||||
|--------|---------|---------|
|
||||
| **Task Progress** | Track implementation work | `openspec view`, `openspec list` |
|
||||
| **Artifact Progress** | Track planning/spec work | `openspec status`, `openspec next` |
|
||||
|
||||
We do NOT merge these systems. Instead, we fix each to work correctly in its domain.
|
||||
|
||||
## Change 1: Fix View Command
|
||||
|
||||
### Current Logic (Buggy)
|
||||
|
||||
```typescript
|
||||
// view.ts line 90
|
||||
if (progress.total === 0 || progress.completed === progress.total) {
|
||||
completed.push({ name: entry.name });
|
||||
}
|
||||
```
|
||||
|
||||
Problem: `total === 0` means "no tasks defined yet", not "all tasks done".
|
||||
|
||||
### New Logic
|
||||
|
||||
```typescript
|
||||
if (progress.total === 0) {
|
||||
draft.push({ name: entry.name });
|
||||
} else if (progress.completed === progress.total) {
|
||||
completed.push({ name: entry.name });
|
||||
} else {
|
||||
active.push({ name: entry.name, progress });
|
||||
}
|
||||
```
|
||||
|
||||
### View Output Change
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Completed Changes
|
||||
─────────────────
|
||||
✓ add-feature (all tasks done - correct)
|
||||
✓ test-workflow (no tasks - WRONG)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Draft Changes
|
||||
─────────────────
|
||||
○ test-workflow (no tasks yet)
|
||||
|
||||
Active Changes
|
||||
─────────────────
|
||||
◉ add-scaffold [████░░░░] 3/7 tasks
|
||||
|
||||
Completed Changes
|
||||
─────────────────
|
||||
✓ add-feature (all tasks done)
|
||||
```
|
||||
|
||||
## Change 2: Fix Artifact Workflow Discovery
|
||||
|
||||
### Current Logic (Buggy)
|
||||
|
||||
```typescript
|
||||
// artifact-workflow.ts - validateChangeExists()
|
||||
const activeChanges = await getActiveChangeIds(projectRoot);
|
||||
if (!activeChanges.includes(changeName)) {
|
||||
throw new Error(`Change '${changeName}' not found...`);
|
||||
}
|
||||
```
|
||||
|
||||
Problem: `getActiveChangeIds()` requires `proposal.md`, but artifact workflow should work on empty directories to help create the first artifact.
|
||||
|
||||
### New Logic
|
||||
|
||||
```typescript
|
||||
async function validateChangeExists(changeName: string, projectRoot: string): Promise<string> {
|
||||
const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
|
||||
|
||||
// Check directory existence directly, not proposal.md
|
||||
if (!fs.existsSync(changePath) || !fs.statSync(changePath).isDirectory()) {
|
||||
// List available changes for helpful error message
|
||||
const entries = await fs.promises.readdir(
|
||||
path.join(projectRoot, 'openspec', 'changes'),
|
||||
{ withFileTypes: true }
|
||||
);
|
||||
const available = entries
|
||||
.filter(e => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
|
||||
.map(e => e.name);
|
||||
|
||||
if (available.length === 0) {
|
||||
throw new Error('No changes found. Create one with: openspec new change <name>');
|
||||
}
|
||||
throw new Error(`Change '${changeName}' not found. Available:\n ${available.join('\n ')}`);
|
||||
}
|
||||
|
||||
return changeName;
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior Change
|
||||
|
||||
```bash
|
||||
# Before
|
||||
$ openspec new change foo
|
||||
$ openspec status --change foo
|
||||
Error: Change 'foo' not found.
|
||||
|
||||
# After
|
||||
$ openspec new change foo
|
||||
$ openspec status --change foo
|
||||
Change: foo
|
||||
Progress: 0/4 artifacts complete
|
||||
|
||||
[ ] proposal
|
||||
[-] specs (blocked by: proposal)
|
||||
[-] design (blocked by: proposal)
|
||||
[-] tasks (blocked by: specs, design)
|
||||
```
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
1. **`getActiveChangeIds()`** - Still requires `proposal.md` (used by validate, show)
|
||||
2. **`getArchivedChangeIds()`** - Unchanged
|
||||
3. **Active/Completed semantics** - Still based on task checkboxes
|
||||
4. **Validation** - Still requires `proposal.md` to have something to validate
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/core/view.ts` | Add draft category, fix completion logic |
|
||||
| `src/commands/artifact-workflow.ts` | Update `validateChangeExists()` to use directory existence |
|
||||
| `test/commands/artifact-workflow.test.ts` | Add tests for scaffolded changes |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit test**: `validateChangeExists()` with scaffolded change
|
||||
2. **View test**: Verify three categories render correctly
|
||||
3. **Manual test**: Full workflow from `new change` → `status` → `view`
|
||||
@@ -1,101 +0,0 @@
|
||||
# Proposal: Unify Change State Model
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Two bugs create inconsistent behavior when working with changes:
|
||||
|
||||
### Bug 1: Empty changes shown as "Completed" in view
|
||||
|
||||
```typescript
|
||||
// view.ts line 90
|
||||
if (progress.total === 0 || progress.completed === progress.total) {
|
||||
completed.push({ name: entry.name }); // BUG: total === 0 ≠ completed
|
||||
}
|
||||
```
|
||||
|
||||
Result: `openspec new change foo && openspec view` shows `foo` as "Completed" when it has no content.
|
||||
|
||||
### Bug 2: Artifact workflow commands can't find scaffolded changes
|
||||
|
||||
```typescript
|
||||
// item-discovery.ts - getActiveChangeIds()
|
||||
const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
|
||||
await fs.access(proposalPath); // Only returns changes WITH proposal.md
|
||||
```
|
||||
|
||||
Result: `openspec status --change foo` says "not found" even though the directory exists.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The system conflates two different concepts:
|
||||
|
||||
| Concept | Question | Source of Truth |
|
||||
|---------|----------|-----------------|
|
||||
| **Planning Progress** | Are all spec documents created? | File existence (ArtifactGraph) |
|
||||
| **Implementation Progress** | Is the coding work done? | Task checkboxes (tasks.md) |
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Fix 1: Add "Draft" state to view command
|
||||
|
||||
Keep Active/Completed with their existing meanings, but fix the bug:
|
||||
|
||||
| State | Criteria | Meaning |
|
||||
|-------|----------|---------|
|
||||
| **Draft** | No tasks.md OR `tasks.total === 0` | Still planning |
|
||||
| **Active** | `tasks.total > 0` AND `completed < total` | Implementing |
|
||||
| **Completed** | `tasks.total > 0` AND `completed === total` | Done |
|
||||
|
||||
### Fix 2: Artifact workflow uses directory existence
|
||||
|
||||
Update `validateChangeExists()` to check if the directory exists, not if `proposal.md` exists. This allows the artifact workflow to guide users through creating their first artifact.
|
||||
|
||||
### Keep existing discovery functions
|
||||
|
||||
`getActiveChangeIds()` continues to require `proposal.md` for backward compatibility with validation and other commands.
|
||||
|
||||
## What Changes
|
||||
|
||||
| Command | Before | After |
|
||||
|---------|--------|-------|
|
||||
| `openspec view` | Empty = "Completed" | Empty = "Draft" |
|
||||
| `openspec status --change X` | Requires proposal.md | Works on any directory |
|
||||
| `openspec validate X` | Requires proposal.md | Unchanged (still requires it) |
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### Minimal Breaking Change
|
||||
|
||||
1. **`openspec view` output**: Empty changes move from "Completed" section to new "Draft" section
|
||||
|
||||
### Non-Breaking
|
||||
|
||||
- Active/Completed semantics unchanged (still task-based)
|
||||
- `getActiveChangeIds()` unchanged
|
||||
- `openspec validate` unchanged
|
||||
- Archived changes unaffected
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Merging task-based and artifact-based progress (they serve different purposes)
|
||||
- Changing what "Completed" means (it stays = all tasks done)
|
||||
- Adding artifact progress to view command (separate enhancement)
|
||||
- Shell tab completions for artifact workflow commands (not yet registered)
|
||||
|
||||
## Related Commands Analysis
|
||||
|
||||
| Command | Uses `getActiveChangeIds()` | Should include scaffolded? | Change needed? |
|
||||
|---------|-----------------------------|-----------------------------|----------------|
|
||||
| `openspec view` | No (reads dirs directly) | Yes → Draft section | **Yes** |
|
||||
| `openspec list` | No (reads dirs directly) | Yes (shows "No tasks") | No |
|
||||
| `openspec status/next/instructions` | Yes | Yes | **Yes** |
|
||||
| `openspec validate` | Yes | No (can't validate empty) | No |
|
||||
| `openspec show` | Yes | No (nothing to show) | No |
|
||||
| Tab completions | Yes | Future enhancement | No |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. `openspec new change foo && openspec view` shows `foo` in "Draft" section
|
||||
2. `openspec new change foo && openspec status --change foo` works
|
||||
3. Changes with all tasks done still show as "Completed"
|
||||
4. All existing tests pass
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
# cli-artifact-workflow Specification Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Status Command
|
||||
|
||||
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
|
||||
|
||||
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
|
||||
|
||||
#### Scenario: Show status with all states
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** the system displays each artifact with status indicator:
|
||||
- `[x]` for completed artifacts
|
||||
- `[ ]` for ready artifacts
|
||||
- `[-]` for blocked artifacts (with missing dependencies listed)
|
||||
|
||||
#### Scenario: Status shows completion summary
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
|
||||
|
||||
#### Scenario: Status JSON output
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id> --json`
|
||||
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
|
||||
|
||||
#### Scenario: Status on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
|
||||
- **THEN** system displays all artifacts with their status
|
||||
- **AND** root artifacts (no dependencies) show as ready `[ ]`
|
||||
- **AND** dependent artifacts show as blocked `[-]`
|
||||
|
||||
#### Scenario: Missing change parameter
|
||||
|
||||
- **WHEN** user runs `openspec status` without `--change`
|
||||
- **THEN** the system displays an error with list of available changes
|
||||
- **AND** includes scaffolded changes (directories without proposal.md)
|
||||
|
||||
#### Scenario: Unknown change
|
||||
|
||||
- **WHEN** user runs `openspec status --change unknown-id`
|
||||
- **AND** directory `openspec/changes/unknown-id/` does not exist
|
||||
- **THEN** the system displays an error listing all available change directories
|
||||
|
||||
### Requirement: Next Command
|
||||
|
||||
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show ready artifacts
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>`
|
||||
- **THEN** the system lists artifacts whose dependencies are all satisfied
|
||||
|
||||
#### Scenario: No artifacts ready
|
||||
|
||||
- **WHEN** all artifacts are either completed or blocked
|
||||
- **THEN** the system indicates no artifacts are ready (with explanation)
|
||||
|
||||
#### Scenario: All artifacts complete
|
||||
|
||||
- **WHEN** all artifacts in the change are completed
|
||||
- **THEN** the system indicates the change is complete
|
||||
|
||||
#### Scenario: Next JSON output
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id> --json`
|
||||
- **THEN** the system outputs JSON array of ready artifact IDs
|
||||
|
||||
#### Scenario: Next on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
|
||||
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
|
||||
|
||||
### Requirement: Instructions Command
|
||||
|
||||
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show enriched instructions
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
|
||||
- **THEN** the system outputs:
|
||||
- Artifact metadata (ID, output path, description)
|
||||
- Template content
|
||||
- Dependency status (done/missing)
|
||||
- Unlocked artifacts (what becomes available after completion)
|
||||
|
||||
#### Scenario: Instructions JSON output
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
|
||||
- **THEN** the system outputs JSON matching ArtifactInstructions interface
|
||||
|
||||
#### Scenario: Unknown artifact
|
||||
|
||||
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
|
||||
- **THEN** the system displays an error listing valid artifact IDs for the schema
|
||||
|
||||
#### Scenario: Artifact with unmet dependencies
|
||||
|
||||
- **WHEN** user requests instructions for a blocked artifact
|
||||
- **THEN** the system displays instructions with a warning about missing dependencies
|
||||
|
||||
#### Scenario: Instructions on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
|
||||
- **THEN** system outputs template and metadata for creating the proposal
|
||||
- **AND** does not require any artifacts to already exist
|
||||
@@ -1,60 +0,0 @@
|
||||
# cli-view Specification Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Draft Changes Display
|
||||
|
||||
The dashboard SHALL display changes without tasks in a separate "Draft" section.
|
||||
|
||||
#### Scenario: Draft changes listing
|
||||
|
||||
- **WHEN** there are changes with no tasks.md or zero tasks defined
|
||||
- **THEN** system shows them in a "Draft Changes" section
|
||||
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
|
||||
|
||||
#### Scenario: Draft section ordering
|
||||
|
||||
- **WHEN** multiple draft changes exist
|
||||
- **THEN** system sorts them alphabetically by name
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Completed Changes Display
|
||||
|
||||
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
|
||||
|
||||
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
|
||||
|
||||
#### Scenario: Completed changes listing
|
||||
|
||||
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
|
||||
- **THEN** system shows them with checkmark indicators in a dedicated section
|
||||
|
||||
#### Scenario: Mixed completion states
|
||||
|
||||
- **WHEN** some changes are complete and others active
|
||||
- **THEN** system separates them into appropriate sections
|
||||
|
||||
#### Scenario: Empty changes not completed
|
||||
|
||||
- **WHEN** a change has no tasks.md or zero tasks defined
|
||||
- **THEN** system does NOT show it in "Completed Changes" section
|
||||
- **AND** shows it in "Draft Changes" section instead
|
||||
|
||||
### Requirement: Summary Section
|
||||
|
||||
The dashboard SHALL display a summary section with key project metrics, including draft change count.
|
||||
|
||||
#### Scenario: Complete summary display
|
||||
|
||||
- **WHEN** dashboard is rendered with specs and changes
|
||||
- **THEN** system shows total number of specifications and requirements
|
||||
- **AND** shows number of draft changes
|
||||
- **AND** shows number of active changes in progress
|
||||
- **AND** shows number of completed changes
|
||||
- **AND** shows overall task progress percentage
|
||||
|
||||
#### Scenario: Empty project summary
|
||||
|
||||
- **WHEN** no specs or changes exist
|
||||
- **THEN** summary shows zero counts for all metrics
|
||||
@@ -1,25 +0,0 @@
|
||||
# Tasks: Unify Change State Model
|
||||
|
||||
## Phase 1: Fix Artifact Workflow Discovery
|
||||
|
||||
- [x] Update `validateChangeExists()` in `artifact-workflow.ts` to check directory existence instead of using `getActiveChangeIds()`
|
||||
- [x] Update error message to list all change directories (not just those with proposal.md)
|
||||
- [x] Add test for `openspec status --change <scaffolded-change>`
|
||||
- [x] Add test for `openspec next --change <scaffolded-change>`
|
||||
- [x] Add test for `openspec instructions proposal --change <scaffolded-change>`
|
||||
|
||||
## Phase 2: Fix View Command
|
||||
|
||||
- [x] Update `getChangesData()` in `view.ts` to return three categories: draft, active, completed
|
||||
- [x] Fix completion logic: `total === 0` → draft, not completed
|
||||
- [x] Add "Draft Changes" section to dashboard rendering
|
||||
- [x] Update summary to include draft count
|
||||
- [x] Add test for draft changes appearing correctly in view
|
||||
|
||||
## Phase 3: Cleanup and Validation
|
||||
|
||||
- [x] Clean up test changes (`test-workflow`, `test-workflow-2`)
|
||||
- [x] Run full test suite
|
||||
- [x] Manual test: `openspec new change foo && openspec status --change foo`
|
||||
- [x] Manual test: `openspec new change foo && openspec view` shows foo in Draft
|
||||
- [x] Validate with `openspec validate unify-change-state-model --strict`
|
||||
@@ -1,130 +0,0 @@
|
||||
# artifact-graph Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-artifact-graph-core. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Schema Loading
|
||||
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
|
||||
|
||||
#### Scenario: Valid schema loaded
|
||||
- **WHEN** a schema directory contains a valid `schema.yaml` file
|
||||
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
|
||||
|
||||
#### Scenario: Invalid schema rejected
|
||||
- **WHEN** a schema YAML file is missing required fields
|
||||
- **THEN** the system throws an error with a descriptive message
|
||||
|
||||
#### Scenario: Cyclic dependencies detected
|
||||
- **WHEN** a schema contains cyclic artifact dependencies
|
||||
- **THEN** the system throws an error listing the artifact IDs in the cycle
|
||||
|
||||
#### Scenario: Invalid dependency reference
|
||||
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
|
||||
- **THEN** the system throws an error identifying the invalid reference
|
||||
|
||||
#### Scenario: Duplicate artifact IDs rejected
|
||||
- **WHEN** a schema contains multiple artifacts with the same ID
|
||||
- **THEN** the system throws an error identifying the duplicate
|
||||
|
||||
#### Scenario: Schema directory not found
|
||||
- **WHEN** resolving a schema name that has no corresponding directory
|
||||
- **THEN** the system throws an error listing available schemas
|
||||
|
||||
### Requirement: Build Order Calculation
|
||||
The system SHALL compute a valid topological build order for artifacts.
|
||||
|
||||
#### Scenario: Linear dependency chain
|
||||
- **WHEN** artifacts form a linear chain (A → B → C)
|
||||
- **THEN** getBuildOrder() returns [A, B, C]
|
||||
|
||||
#### Scenario: Diamond dependency
|
||||
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
|
||||
- **THEN** getBuildOrder() returns A before B and C, and D last
|
||||
|
||||
#### Scenario: Independent artifacts
|
||||
- **WHEN** artifacts have no dependencies
|
||||
- **THEN** getBuildOrder() returns them in a stable order
|
||||
|
||||
### Requirement: State Detection
|
||||
The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
|
||||
#### Scenario: Simple file exists
|
||||
- **WHEN** an artifact generates "proposal.md" and the file exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Simple file missing
|
||||
- **WHEN** an artifact generates "proposal.md" and the file does not exist
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Glob pattern with files
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Glob pattern empty
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Missing change directory
|
||||
- **WHEN** the change directory does not exist
|
||||
- **THEN** all artifacts are marked as not completed (empty state)
|
||||
|
||||
### Requirement: Ready Artifact Query
|
||||
The system SHALL identify which artifacts are ready to be created based on dependency completion.
|
||||
|
||||
#### Scenario: Root artifacts ready initially
|
||||
- **WHEN** no artifacts are completed
|
||||
- **THEN** getNextArtifacts() returns artifacts with no dependencies
|
||||
|
||||
#### Scenario: Dependent artifact becomes ready
|
||||
- **WHEN** an artifact's dependencies are all completed
|
||||
- **THEN** getNextArtifacts() includes that artifact
|
||||
|
||||
#### Scenario: Blocked artifacts excluded
|
||||
- **WHEN** an artifact has uncompleted dependencies
|
||||
- **THEN** getNextArtifacts() does not include that artifact
|
||||
|
||||
### Requirement: Completion Check
|
||||
The system SHALL determine when all artifacts in a graph are complete.
|
||||
|
||||
#### Scenario: All complete
|
||||
- **WHEN** all artifacts in the graph are in the completed set
|
||||
- **THEN** isComplete() returns true
|
||||
|
||||
#### Scenario: Partially complete
|
||||
- **WHEN** some artifacts in the graph are not completed
|
||||
- **THEN** isComplete() returns false
|
||||
|
||||
### Requirement: Blocked Query
|
||||
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
|
||||
|
||||
#### Scenario: Artifact blocked by single dependency
|
||||
- **WHEN** artifact B requires artifact A and A is not complete
|
||||
- **THEN** getBlocked() returns `{ B: ['A'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by multiple dependencies
|
||||
- **WHEN** artifact C requires A and B, and only A is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['B'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by all dependencies
|
||||
- **WHEN** artifact C requires A and B, and neither is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
|
||||
|
||||
### Requirement: Schema Directory Structure
|
||||
The system SHALL support self-contained schema directories with co-located templates.
|
||||
|
||||
#### Scenario: Schema with templates
|
||||
- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory
|
||||
- **THEN** artifacts can reference templates relative to the schema's templates directory
|
||||
|
||||
#### Scenario: User schema override
|
||||
- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`
|
||||
- **THEN** the system uses that directory instead of the built-in
|
||||
|
||||
#### Scenario: Built-in schema fallback
|
||||
- **WHEN** no user override exists for a schema
|
||||
- **THEN** the system uses the package built-in schema directory
|
||||
|
||||
#### Scenario: List available schemas
|
||||
- **WHEN** listing schemas
|
||||
- **THEN** the system returns schema names from both user and package directories
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# change-creation Specification
|
||||
|
||||
## Purpose
|
||||
Provide programmatic utilities for creating and validating OpenSpec change directories.
|
||||
## Requirements
|
||||
### Requirement: Change Creation
|
||||
The system SHALL provide a function to create new change directories programmatically.
|
||||
|
||||
#### Scenario: Create change
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
|
||||
- **THEN** the system creates `openspec/changes/add-auth/` directory
|
||||
|
||||
#### Scenario: Duplicate change rejected
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
|
||||
- **THEN** the system throws an error indicating the change already exists
|
||||
|
||||
#### Scenario: Creates parent directories if needed
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
|
||||
- **THEN** the system creates the full path including parent directories
|
||||
|
||||
#### Scenario: Invalid change name rejected
|
||||
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
|
||||
- **THEN** the system throws a validation error
|
||||
|
||||
### Requirement: Change Name Validation
|
||||
The system SHALL validate change names follow kebab-case conventions.
|
||||
|
||||
#### Scenario: Valid kebab-case name accepted
|
||||
- **WHEN** a change name like `add-user-auth` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Numeric suffixes accepted
|
||||
- **WHEN** a change name like `add-feature-2` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Single word accepted
|
||||
- **WHEN** a change name like `refactor` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Uppercase characters rejected
|
||||
- **WHEN** a change name like `Add-Auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Spaces rejected
|
||||
- **WHEN** a change name like `add auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Underscores rejected
|
||||
- **WHEN** a change name like `add_auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Special characters rejected
|
||||
- **WHEN** a change name like `add-auth!` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Leading hyphen rejected
|
||||
- **WHEN** a change name like `-add-auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Trailing hyphen rejected
|
||||
- **WHEN** a change name like `add-auth-` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Consecutive hyphens rejected
|
||||
- **WHEN** a change name like `add--auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# cli-artifact-workflow Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-artifact-workflow-cli. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Status Command
|
||||
|
||||
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
|
||||
|
||||
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
|
||||
|
||||
#### Scenario: Show status with all states
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** the system displays each artifact with status indicator:
|
||||
- `[x]` for completed artifacts
|
||||
- `[ ]` for ready artifacts
|
||||
- `[-]` for blocked artifacts (with missing dependencies listed)
|
||||
|
||||
#### Scenario: Status shows completion summary
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
|
||||
|
||||
#### Scenario: Status JSON output
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id> --json`
|
||||
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
|
||||
|
||||
#### Scenario: Status on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
|
||||
- **THEN** system displays all artifacts with their status
|
||||
- **AND** root artifacts (no dependencies) show as ready `[ ]`
|
||||
- **AND** dependent artifacts show as blocked `[-]`
|
||||
|
||||
#### Scenario: Missing change parameter
|
||||
|
||||
- **WHEN** user runs `openspec status` without `--change`
|
||||
- **THEN** the system displays an error with list of available changes
|
||||
- **AND** includes scaffolded changes (directories without proposal.md)
|
||||
|
||||
#### Scenario: Unknown change
|
||||
|
||||
- **WHEN** user runs `openspec status --change unknown-id`
|
||||
- **AND** directory `openspec/changes/unknown-id/` does not exist
|
||||
- **THEN** the system displays an error listing all available change directories
|
||||
|
||||
### Requirement: Next Command
|
||||
|
||||
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show ready artifacts
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>`
|
||||
- **THEN** the system lists artifacts whose dependencies are all satisfied
|
||||
|
||||
#### Scenario: No artifacts ready
|
||||
|
||||
- **WHEN** all artifacts are either completed or blocked
|
||||
- **THEN** the system indicates no artifacts are ready (with explanation)
|
||||
|
||||
#### Scenario: All artifacts complete
|
||||
|
||||
- **WHEN** all artifacts in the change are completed
|
||||
- **THEN** the system indicates the change is complete
|
||||
|
||||
#### Scenario: Next JSON output
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id> --json`
|
||||
- **THEN** the system outputs JSON array of ready artifact IDs
|
||||
|
||||
#### Scenario: Next on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
|
||||
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
|
||||
|
||||
### Requirement: Instructions Command
|
||||
|
||||
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show enriched instructions
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
|
||||
- **THEN** the system outputs:
|
||||
- Artifact metadata (ID, output path, description)
|
||||
- Template content
|
||||
- Dependency status (done/missing)
|
||||
- Unlocked artifacts (what becomes available after completion)
|
||||
|
||||
#### Scenario: Instructions JSON output
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
|
||||
- **THEN** the system outputs JSON matching ArtifactInstructions interface
|
||||
|
||||
#### Scenario: Unknown artifact
|
||||
|
||||
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
|
||||
- **THEN** the system displays an error listing valid artifact IDs for the schema
|
||||
|
||||
#### Scenario: Artifact with unmet dependencies
|
||||
|
||||
- **WHEN** user requests instructions for a blocked artifact
|
||||
- **THEN** the system displays instructions with a warning about missing dependencies
|
||||
|
||||
#### Scenario: Instructions on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
|
||||
- **THEN** system outputs template and metadata for creating the proposal
|
||||
- **AND** does not require any artifacts to already exist
|
||||
|
||||
### Requirement: Templates Command
|
||||
The system SHALL show resolved template paths for all artifacts in a schema.
|
||||
|
||||
#### Scenario: List template paths with default schema
|
||||
- **WHEN** user runs `openspec templates`
|
||||
- **THEN** the system displays each artifact with its resolved template path using the default schema
|
||||
|
||||
#### Scenario: List template paths with custom schema
|
||||
- **WHEN** user runs `openspec templates --schema tdd`
|
||||
- **THEN** the system displays template paths for the specified schema
|
||||
|
||||
#### Scenario: Templates JSON output
|
||||
- **WHEN** user runs `openspec templates --json`
|
||||
- **THEN** the system outputs JSON mapping artifact IDs to template paths
|
||||
|
||||
#### Scenario: Template resolution source
|
||||
- **WHEN** displaying template paths
|
||||
- **THEN** the system indicates whether each template is from user override or package built-in
|
||||
|
||||
### Requirement: New Change Command
|
||||
The system SHALL create new change directories with validation.
|
||||
|
||||
#### Scenario: Create valid change
|
||||
- **WHEN** user runs `openspec new change add-feature`
|
||||
- **THEN** the system creates `openspec/changes/add-feature/` directory
|
||||
|
||||
#### Scenario: Invalid change name
|
||||
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
|
||||
- **THEN** the system displays validation error with guidance
|
||||
|
||||
#### Scenario: Duplicate change name
|
||||
- **WHEN** user runs `openspec new change existing-change` for an existing change
|
||||
- **THEN** the system displays an error indicating the change already exists
|
||||
|
||||
#### Scenario: Create with description
|
||||
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
|
||||
- **THEN** the system creates the change directory with description in README.md
|
||||
|
||||
### Requirement: Schema Selection
|
||||
The system SHALL support custom schema selection for workflow commands.
|
||||
|
||||
#### Scenario: Default schema
|
||||
- **WHEN** user runs workflow commands without `--schema`
|
||||
- **THEN** the system uses the "spec-driven" schema
|
||||
|
||||
#### Scenario: Custom schema
|
||||
- **WHEN** user runs `openspec status --change <id> --schema tdd`
|
||||
- **THEN** the system uses the specified schema for artifact graph
|
||||
|
||||
#### Scenario: Unknown schema
|
||||
- **WHEN** user specifies an unknown schema
|
||||
- **THEN** the system displays an error listing available schemas
|
||||
|
||||
### Requirement: Output Formatting
|
||||
The system SHALL provide consistent output formatting.
|
||||
|
||||
#### Scenario: Color output
|
||||
- **WHEN** terminal supports colors
|
||||
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
|
||||
|
||||
#### Scenario: No color output
|
||||
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
|
||||
- **THEN** output uses text-only indicators without ANSI colors
|
||||
|
||||
#### Scenario: Progress indication
|
||||
- **WHEN** loading change state takes time
|
||||
- **THEN** the system displays a spinner during loading
|
||||
|
||||
### Requirement: Experimental Isolation
|
||||
The system SHALL implement artifact workflow commands in isolation for easy removal.
|
||||
|
||||
#### Scenario: Single file implementation
|
||||
- **WHEN** artifact workflow feature is implemented
|
||||
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
|
||||
|
||||
#### Scenario: Help text marking
|
||||
- **WHEN** user runs `--help` on any artifact workflow command
|
||||
- **THEN** help text indicates the command is experimental
|
||||
|
||||
@@ -20,13 +20,12 @@ The system SHALL provide a `view` command that displays a dashboard overview of
|
||||
|
||||
### Requirement: Summary Section
|
||||
|
||||
The dashboard SHALL display a summary section with key project metrics, including draft change count.
|
||||
The dashboard SHALL display a summary section with key project metrics.
|
||||
|
||||
#### Scenario: Complete summary display
|
||||
|
||||
- **WHEN** dashboard is rendered with specs and changes
|
||||
- **THEN** system shows total number of specifications and requirements
|
||||
- **AND** shows number of draft changes
|
||||
- **AND** shows number of active changes in progress
|
||||
- **AND** shows number of completed changes
|
||||
- **AND** shows overall task progress percentage
|
||||
@@ -47,13 +46,11 @@ The dashboard SHALL show active changes with visual progress indicators.
|
||||
|
||||
### Requirement: Completed Changes Display
|
||||
|
||||
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
|
||||
|
||||
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
|
||||
The dashboard SHALL list completed changes in a separate section.
|
||||
|
||||
#### Scenario: Completed changes listing
|
||||
|
||||
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
|
||||
- **WHEN** there are completed changes (all tasks done)
|
||||
- **THEN** system shows them with checkmark indicators in a dedicated section
|
||||
|
||||
#### Scenario: Mixed completion states
|
||||
@@ -61,12 +58,6 @@ The dashboard SHALL list completed changes in a separate section, only showing c
|
||||
- **WHEN** some changes are complete and others active
|
||||
- **THEN** system separates them into appropriate sections
|
||||
|
||||
#### Scenario: Empty changes not completed
|
||||
|
||||
- **WHEN** a change has no tasks.md or zero tasks defined
|
||||
- **THEN** system does NOT show it in "Completed Changes" section
|
||||
- **AND** shows it in "Draft Changes" section instead
|
||||
|
||||
### Requirement: Specifications Display
|
||||
|
||||
The dashboard SHALL display specifications sorted by requirement count.
|
||||
@@ -112,18 +103,3 @@ The view command SHALL handle errors gracefully.
|
||||
- **WHEN** specs or changes have invalid format
|
||||
- **THEN** system skips invalid items and continues rendering
|
||||
|
||||
### Requirement: Draft Changes Display
|
||||
|
||||
The dashboard SHALL display changes without tasks in a separate "Draft" section.
|
||||
|
||||
#### Scenario: Draft changes listing
|
||||
|
||||
- **WHEN** there are changes with no tasks.md or zero tasks defined
|
||||
- **THEN** system shows them in a "Draft Changes" section
|
||||
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
|
||||
|
||||
#### Scenario: Draft section ordering
|
||||
|
||||
- **WHEN** multiple draft changes exist
|
||||
- **THEN** system sorts them alphabetically by name
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# instruction-loader Specification
|
||||
|
||||
## Purpose
|
||||
The instruction-loader loads instruction templates from schema directories, validates and enriches them with metadata and parameters (such as change context and dependency status), and exposes them for use by downstream services including template retrieval, parameter substitution, and enrichment.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Template Loading
|
||||
The system SHALL load templates from schema directories.
|
||||
|
||||
#### Scenario: Load template from schema directory
|
||||
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
|
||||
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
|
||||
|
||||
#### Scenario: Template file not found
|
||||
- **WHEN** a template file does not exist in the schema's templates directory
|
||||
- **THEN** the system throws an error with the template path
|
||||
|
||||
### Requirement: Change Context Loading
|
||||
The system SHALL load change context combining graph and completion state.
|
||||
|
||||
#### Scenario: Load context for existing change
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change
|
||||
- **THEN** the system returns a context with graph, completed set, schema name, and change info
|
||||
|
||||
#### Scenario: Load context with custom schema
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called
|
||||
- **THEN** the system uses the specified schema instead of default
|
||||
|
||||
#### Scenario: Load context for non-existent change directory
|
||||
- **WHEN** `loadChangeContext` is called for a non-existent change directory
|
||||
- **THEN** the system returns context with empty completed set
|
||||
|
||||
### Requirement: Template Enrichment
|
||||
The system SHALL enrich templates with change-specific context.
|
||||
|
||||
#### Scenario: Include artifact metadata
|
||||
- **WHEN** instructions are generated for an artifact
|
||||
- **THEN** the output includes change name, artifact ID, schema name, and output path
|
||||
|
||||
#### Scenario: Include dependency status
|
||||
- **WHEN** an artifact has dependencies
|
||||
- **THEN** the output shows each dependency with completion status (done/missing)
|
||||
|
||||
#### Scenario: Include unlocked artifacts
|
||||
- **WHEN** instructions are generated
|
||||
- **THEN** the output includes which artifacts become available after this one
|
||||
|
||||
#### Scenario: Root artifact indicator
|
||||
- **WHEN** an artifact has no dependencies
|
||||
- **THEN** the dependency section indicates this is a root artifact
|
||||
|
||||
### Requirement: Status Formatting
|
||||
The system SHALL format change status as readable output.
|
||||
|
||||
#### Scenario: All artifacts completed
|
||||
- **WHEN** all artifacts are completed
|
||||
- **THEN** status shows all artifacts as "done"
|
||||
|
||||
#### Scenario: Mixed completion status
|
||||
- **WHEN** some artifacts are completed
|
||||
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
|
||||
|
||||
#### Scenario: Blocked artifact details
|
||||
- **WHEN** an artifact is blocked
|
||||
- **THEN** status shows which dependencies are missing
|
||||
|
||||
#### Scenario: Include output paths
|
||||
- **WHEN** status is formatted
|
||||
- **THEN** each artifact shows its output path pattern
|
||||
|
||||
+1
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.1",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -32,7 +32,6 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"schemas",
|
||||
"scripts/postinstall.js",
|
||||
"!dist/**/*.test.js",
|
||||
"!dist/**/__tests__",
|
||||
@@ -74,9 +73,7 @@
|
||||
"@inquirer/prompts": "^7.8.0",
|
||||
"chalk": "^5.5.0",
|
||||
"commander": "^14.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ora": "^8.2.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+11
-25
@@ -20,15 +20,9 @@ importers:
|
||||
commander:
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0
|
||||
fast-glob:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
ora:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
@@ -53,7 +47,7 @@ importers:
|
||||
version: 8.50.1(eslint@9.39.2)(typescript@5.9.3)
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
|
||||
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1582,11 +1576,6 @@ packages:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yaml@2.8.2:
|
||||
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2213,13 +2202,13 @@ snapshots:
|
||||
chai: 5.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
optionalDependencies:
|
||||
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
|
||||
vite: 7.0.6(@types/node@24.2.0)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -2250,7 +2239,7 @@ snapshots:
|
||||
sirv: 3.0.1
|
||||
tinyglobby: 0.2.14
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
|
||||
vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -2998,13 +2987,13 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
vite-node@3.2.4(@types/node@24.2.0)(yaml@2.8.2):
|
||||
vite-node@3.2.4(@types/node@24.2.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.1
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
|
||||
vite: 7.0.6(@types/node@24.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -3019,7 +3008,7 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2):
|
||||
vite@7.0.6(@types/node@24.2.0):
|
||||
dependencies:
|
||||
esbuild: 0.25.8
|
||||
fdir: 6.4.6(picomatch@4.0.3)
|
||||
@@ -3030,13 +3019,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.2.0
|
||||
fsevents: 2.3.3
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2):
|
||||
vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.2
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@@ -3054,8 +3042,8 @@ snapshots:
|
||||
tinyglobby: 0.2.14
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
|
||||
vite-node: 3.2.4(@types/node@24.2.0)(yaml@2.8.2)
|
||||
vite: 7.0.6(@types/node@24.2.0)
|
||||
vite-node: 3.2.4(@types/node@24.2.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.2.0
|
||||
@@ -3091,8 +3079,6 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors-cjs@2.1.2: {}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
name: spec-driven
|
||||
version: 1
|
||||
description: Default OpenSpec workflow - proposal → specs → design → tasks
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Initial proposal document outlining the change
|
||||
template: proposal.md
|
||||
requires: []
|
||||
- id: specs
|
||||
generates: "specs/**/*.md"
|
||||
description: Detailed specifications for the change
|
||||
template: spec.md
|
||||
requires:
|
||||
- proposal
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: Technical design document with implementation details
|
||||
template: design.md
|
||||
requires:
|
||||
- proposal
|
||||
- id: tasks
|
||||
generates: tasks.md
|
||||
description: Implementation tasks derived from specs and design
|
||||
template: tasks.md
|
||||
requires:
|
||||
- specs
|
||||
- design
|
||||
@@ -1,19 +0,0 @@
|
||||
## Context
|
||||
|
||||
<!-- Background and current state -->
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
<!-- What this design aims to achieve -->
|
||||
|
||||
**Non-Goals:**
|
||||
<!-- What is explicitly out of scope -->
|
||||
|
||||
## Decisions
|
||||
|
||||
<!-- Key design decisions and rationale -->
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
<!-- Known risks and trade-offs -->
|
||||
@@ -1,11 +0,0 @@
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change -->
|
||||
|
||||
## What Changes
|
||||
|
||||
<!-- Describe what will change -->
|
||||
|
||||
## Impact
|
||||
|
||||
<!-- List affected areas -->
|
||||
@@ -1,8 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <!-- requirement name -->
|
||||
<!-- requirement text -->
|
||||
|
||||
#### Scenario: <!-- scenario name -->
|
||||
- **WHEN** <!-- condition -->
|
||||
- **THEN** <!-- expected outcome -->
|
||||
@@ -1,9 +0,0 @@
|
||||
## 1. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 1.1 <!-- Task description -->
|
||||
- [ ] 1.2 <!-- Task description -->
|
||||
|
||||
## 2. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 2.1 <!-- Task description -->
|
||||
- [ ] 2.2 <!-- Task description -->
|
||||
@@ -1,27 +0,0 @@
|
||||
name: tdd
|
||||
version: 1
|
||||
description: Test-driven development workflow - tests → implementation → docs
|
||||
artifacts:
|
||||
- id: spec
|
||||
generates: spec.md
|
||||
description: Feature specification defining requirements
|
||||
template: spec.md
|
||||
requires: []
|
||||
- id: tests
|
||||
generates: "tests/*.test.ts"
|
||||
description: Test files written before implementation
|
||||
template: test.md
|
||||
requires:
|
||||
- spec
|
||||
- id: implementation
|
||||
generates: "src/*.ts"
|
||||
description: Implementation code to pass the tests
|
||||
template: implementation.md
|
||||
requires:
|
||||
- tests
|
||||
- id: docs
|
||||
generates: "docs/*.md"
|
||||
description: Documentation for the implemented feature
|
||||
template: docs.md
|
||||
requires:
|
||||
- implementation
|
||||
@@ -1,15 +0,0 @@
|
||||
## Overview
|
||||
|
||||
<!-- Feature overview -->
|
||||
|
||||
## Getting Started
|
||||
|
||||
<!-- Quick start guide -->
|
||||
|
||||
## Examples
|
||||
|
||||
<!-- Code examples -->
|
||||
|
||||
## Reference
|
||||
|
||||
<!-- API reference or additional details -->
|
||||
@@ -1,11 +0,0 @@
|
||||
## Implementation Notes
|
||||
|
||||
<!-- Technical implementation details -->
|
||||
|
||||
## API
|
||||
|
||||
<!-- Public API documentation -->
|
||||
|
||||
## Usage
|
||||
|
||||
<!-- Usage examples -->
|
||||
@@ -1,11 +0,0 @@
|
||||
## Feature: <!-- feature name -->
|
||||
|
||||
<!-- Feature description -->
|
||||
|
||||
## Requirements
|
||||
|
||||
<!-- List of requirements -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- List of acceptance criteria -->
|
||||
@@ -1,11 +0,0 @@
|
||||
## Test Plan
|
||||
|
||||
<!-- Describe the testing strategy -->
|
||||
|
||||
## Test Cases
|
||||
|
||||
### <!-- Test case name -->
|
||||
|
||||
- **Given:** <!-- preconditions -->
|
||||
- **When:** <!-- action -->
|
||||
- **Then:** <!-- expected result -->
|
||||
@@ -14,7 +14,6 @@ import { ValidateCommand } from '../commands/validate.js';
|
||||
import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
|
||||
|
||||
const program = new Command();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -317,7 +316,4 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Register artifact workflow commands (experimental)
|
||||
registerArtifactWorkflowCommands(program);
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
/**
|
||||
* Artifact Workflow CLI Commands (Experimental)
|
||||
*
|
||||
* This file contains all artifact workflow commands in isolation for easy removal.
|
||||
* Commands expose the ArtifactGraph and InstructionLoader APIs to users and agents.
|
||||
*
|
||||
* To remove this feature:
|
||||
* 1. Delete this file
|
||||
* 2. Remove the registerArtifactWorkflowCommands() call from src/cli/index.ts
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import ora from 'ora';
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
loadChangeContext,
|
||||
formatChangeStatus,
|
||||
generateInstructions,
|
||||
listSchemas,
|
||||
getSchemaDir,
|
||||
resolveSchema,
|
||||
ArtifactGraph,
|
||||
type ChangeStatus,
|
||||
type ArtifactInstructions,
|
||||
} from '../core/artifact-graph/index.js';
|
||||
import { createChange, validateChangeName } from '../utils/change-utils.js';
|
||||
|
||||
const DEFAULT_SCHEMA = 'spec-driven';
|
||||
|
||||
/**
|
||||
* Checks if color output is disabled via NO_COLOR env or --no-color flag.
|
||||
*/
|
||||
function isColorDisabled(): boolean {
|
||||
return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the color function based on status.
|
||||
*/
|
||||
function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string {
|
||||
if (isColorDisabled()) {
|
||||
return (text: string) => text;
|
||||
}
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return chalk.green;
|
||||
case 'ready':
|
||||
return chalk.yellow;
|
||||
case 'blocked':
|
||||
return chalk.red;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status indicator for an artifact.
|
||||
*/
|
||||
function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string {
|
||||
const color = getStatusColor(status);
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return color('[x]');
|
||||
case 'ready':
|
||||
return color('[ ]');
|
||||
case 'blocked':
|
||||
return color('[-]');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a change exists and returns available changes if not.
|
||||
* Checks directory existence directly to support scaffolded changes (without proposal.md).
|
||||
*/
|
||||
async function validateChangeExists(
|
||||
changeName: string | undefined,
|
||||
projectRoot: string
|
||||
): Promise<string> {
|
||||
const changesPath = path.join(projectRoot, 'openspec', 'changes');
|
||||
|
||||
// Get all change directories (not just those with proposal.md)
|
||||
const getAvailableChanges = async (): Promise<string[]> => {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
if (!changeName) {
|
||||
const available = await getAvailableChanges();
|
||||
if (available.length === 0) {
|
||||
throw new Error('No changes found. Create one with: openspec new change <name>');
|
||||
}
|
||||
throw new Error(
|
||||
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate change name format to prevent path traversal
|
||||
const nameValidation = validateChangeName(changeName);
|
||||
if (!nameValidation.valid) {
|
||||
throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`);
|
||||
}
|
||||
|
||||
// Check directory existence directly
|
||||
const changePath = path.join(changesPath, changeName);
|
||||
const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
|
||||
|
||||
if (!exists) {
|
||||
const available = await getAvailableChanges();
|
||||
if (available.length === 0) {
|
||||
throw new Error(
|
||||
`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return changeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a schema exists and returns available schemas if not.
|
||||
*/
|
||||
function validateSchemaExists(schemaName: string): string {
|
||||
const schemaDir = getSchemaDir(schemaName);
|
||||
if (!schemaDir) {
|
||||
const availableSchemas = listSchemas();
|
||||
throw new Error(
|
||||
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
|
||||
);
|
||||
}
|
||||
return schemaName;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Status Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface StatusOptions {
|
||||
change?: string;
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
const spinner = ora('Loading change status...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const changeName = await validateChangeExists(options.change, projectRoot);
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
|
||||
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printStatusText(status);
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function printStatusText(status: ChangeStatus): void {
|
||||
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
|
||||
const total = status.artifacts.length;
|
||||
|
||||
console.log(`Change: ${status.changeName}`);
|
||||
console.log(`Schema: ${status.schemaName}`);
|
||||
console.log(`Progress: ${doneCount}/${total} artifacts complete`);
|
||||
console.log();
|
||||
|
||||
for (const artifact of status.artifacts) {
|
||||
const indicator = getStatusIndicator(artifact.status);
|
||||
const color = getStatusColor(artifact.status);
|
||||
let line = `${indicator} ${artifact.id}`;
|
||||
|
||||
if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) {
|
||||
line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`);
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
if (status.isComplete) {
|
||||
console.log();
|
||||
console.log(chalk.green('All artifacts complete!'));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Next Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface NextOptions {
|
||||
change?: string;
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
async function nextCommand(options: NextOptions): Promise<void> {
|
||||
const spinner = ora('Finding next artifacts...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const changeName = await validateChangeExists(options.change, projectRoot);
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
|
||||
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
||||
const ready = context.graph.getNextArtifacts(context.completed);
|
||||
const isComplete = context.graph.isComplete(context.completed);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(ready, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
console.log(chalk.green('All artifacts are complete!'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
console.log('No artifacts are ready. All remaining artifacts are blocked.');
|
||||
console.log('Run `openspec status --change ' + changeName + '` to see blocked dependencies.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Artifacts ready to create:');
|
||||
for (const artifactId of ready) {
|
||||
const color = getStatusColor('ready');
|
||||
console.log(color(` ${artifactId}`));
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Instructions Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface InstructionsOptions {
|
||||
change?: string;
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
async function instructionsCommand(
|
||||
artifactId: string | undefined,
|
||||
options: InstructionsOptions
|
||||
): Promise<void> {
|
||||
const spinner = ora('Generating instructions...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const changeName = await validateChangeExists(options.change, projectRoot);
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
|
||||
if (!artifactId) {
|
||||
spinner.stop();
|
||||
const schema = resolveSchema(schemaName);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
const validIds = graph.getAllArtifacts().map((a) => a.id);
|
||||
throw new Error(
|
||||
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
||||
const artifact = context.graph.getArtifact(artifactId);
|
||||
|
||||
if (!artifact) {
|
||||
spinner.stop();
|
||||
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
|
||||
throw new Error(
|
||||
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const instructions = generateInstructions(context, artifactId);
|
||||
const isBlocked = instructions.dependencies.some((d) => !d.done);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(instructions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printInstructionsText(instructions, isBlocked);
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void {
|
||||
if (isBlocked) {
|
||||
console.log(chalk.yellow('Warning: This artifact has unmet dependencies.'));
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`Artifact: ${instructions.artifactId}`);
|
||||
console.log(`Output: ${instructions.outputPath}`);
|
||||
console.log(`Description: ${instructions.description}`);
|
||||
console.log();
|
||||
|
||||
console.log('Dependencies:');
|
||||
if (instructions.dependencies.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const dep of instructions.dependencies) {
|
||||
const status = dep.done ? chalk.green('[done]') : chalk.red('[missing]');
|
||||
console.log(` ${status} ${dep.id}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (instructions.unlocks.length > 0) {
|
||||
console.log('Unlocks:');
|
||||
for (const unlocked of instructions.unlocks) {
|
||||
console.log(` ${unlocked}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log('Template:');
|
||||
console.log('─'.repeat(40));
|
||||
console.log(instructions.template);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Templates Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface TemplatesOptions {
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface TemplateInfo {
|
||||
artifactId: string;
|
||||
templatePath: string;
|
||||
source: 'user' | 'package';
|
||||
}
|
||||
|
||||
async function templatesCommand(options: TemplatesOptions): Promise<void> {
|
||||
const spinner = ora('Loading templates...').start();
|
||||
|
||||
try {
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
const schema = resolveSchema(schemaName);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
const schemaDir = getSchemaDir(schemaName)!;
|
||||
|
||||
// Determine if this is a user override or package built-in
|
||||
const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js');
|
||||
const userSchemasDir = getUserSchemasDir();
|
||||
const isUserOverride = schemaDir.startsWith(userSchemasDir);
|
||||
|
||||
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
|
||||
artifactId: artifact.id,
|
||||
templatePath: path.join(schemaDir, 'templates', artifact.template),
|
||||
source: isUserOverride ? 'user' : 'package',
|
||||
}));
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
const output: Record<string, { path: string; source: string }> = {};
|
||||
for (const t of templates) {
|
||||
output[t.artifactId] = { path: t.templatePath, source: t.source };
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Schema: ${schemaName}`);
|
||||
console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`);
|
||||
console.log();
|
||||
|
||||
for (const t of templates) {
|
||||
console.log(`${t.artifactId}:`);
|
||||
console.log(` ${t.templatePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// New Change Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface NewChangeOptions {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
|
||||
if (!name) {
|
||||
throw new Error('Missing required argument <name>');
|
||||
}
|
||||
|
||||
const validation = validateChangeName(name);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const spinner = ora(`Creating change '${name}'...`).start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
await createChange(projectRoot, name);
|
||||
|
||||
// If description provided, create README.md with description
|
||||
if (options.description) {
|
||||
const { promises: fs } = await import('fs');
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
|
||||
const readmePath = path.join(changeDir, 'README.md');
|
||||
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to create change '${name}'`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Command Registration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Registers all artifact workflow commands on the given program.
|
||||
* All commands are marked as experimental in their help text.
|
||||
*/
|
||||
export function registerArtifactWorkflowCommands(program: Command): void {
|
||||
// Status command
|
||||
program
|
||||
.command('status')
|
||||
.description('[Experimental] Display artifact completion status for a change')
|
||||
.option('--change <id>', 'Change name to show status for')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StatusOptions) => {
|
||||
try {
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Next command
|
||||
program
|
||||
.command('next')
|
||||
.description('[Experimental] Show artifacts ready to be created')
|
||||
.option('--change <id>', 'Change name to check')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON array of ready artifact IDs')
|
||||
.action(async (options: NextOptions) => {
|
||||
try {
|
||||
await nextCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Instructions command
|
||||
program
|
||||
.command('instructions [artifact]')
|
||||
.description('[Experimental] Output enriched instructions for creating an artifact')
|
||||
.option('--change <id>', 'Change name')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
|
||||
try {
|
||||
await instructionsCommand(artifactId, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Templates command
|
||||
program
|
||||
.command('templates')
|
||||
.description('[Experimental] Show resolved template paths for all artifacts in a schema')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
|
||||
.action(async (options: TemplatesOptions) => {
|
||||
try {
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// New command group with change subcommand
|
||||
const newCmd = program.command('new').description('[Experimental] Create new items');
|
||||
|
||||
newCmd
|
||||
.command('change <name>')
|
||||
.description('[Experimental] Create a new change directory')
|
||||
.option('--description <text>', 'Description to add to README.md')
|
||||
.action(async (name: string, options: NewChangeOptions) => {
|
||||
try {
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
+7
-23
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
|
||||
import { Validator } from './validation/validator.js';
|
||||
import chalk from 'chalk';
|
||||
@@ -446,26 +447,15 @@ export class ArchiveCommand {
|
||||
|
||||
// Load or create base target content
|
||||
let targetContent: string;
|
||||
let isNewSpec = false;
|
||||
try {
|
||||
targetContent = await fs.readFile(update.target, 'utf-8');
|
||||
} catch {
|
||||
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
|
||||
// REMOVED will be ignored with a warning since there's nothing to remove
|
||||
if (plan.modified.length > 0 || plan.renamed.length > 0) {
|
||||
// Target spec does not exist; only ADDED operations are permitted
|
||||
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
|
||||
throw new Error(
|
||||
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`
|
||||
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`
|
||||
);
|
||||
}
|
||||
// Warn about REMOVED requirements being ignored for new specs
|
||||
if (plan.removed.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`
|
||||
)
|
||||
);
|
||||
}
|
||||
isNewSpec = true;
|
||||
targetContent = this.buildSpecSkeleton(specName, changeName);
|
||||
}
|
||||
|
||||
@@ -508,15 +498,9 @@ export class ArchiveCommand {
|
||||
for (const name of plan.removed) {
|
||||
const key = normalizeRequirementName(name);
|
||||
if (!nameToBlock.has(key)) {
|
||||
// For new specs, REMOVED requirements are already warned about and ignored
|
||||
// For existing specs, missing requirements are an error
|
||||
if (!isNewSpec) {
|
||||
throw new Error(
|
||||
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
|
||||
);
|
||||
}
|
||||
// Skip removal for new specs (already warned above)
|
||||
continue;
|
||||
throw new Error(
|
||||
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
|
||||
);
|
||||
}
|
||||
nameToBlock.delete(key);
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './types.js';
|
||||
import { loadSchema, parseSchema } from './schema.js';
|
||||
|
||||
/**
|
||||
* Represents an artifact dependency graph.
|
||||
* Provides methods for querying build order, ready artifacts, and completion status.
|
||||
*/
|
||||
export class ArtifactGraph {
|
||||
private artifacts: Map<string, Artifact>;
|
||||
private schema: SchemaYaml;
|
||||
|
||||
private constructor(schema: SchemaYaml) {
|
||||
this.schema = schema;
|
||||
this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ArtifactGraph from a YAML file path.
|
||||
*/
|
||||
static fromYaml(filePath: string): ArtifactGraph {
|
||||
const schema = loadSchema(filePath);
|
||||
return new ArtifactGraph(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ArtifactGraph from YAML content string.
|
||||
*/
|
||||
static fromYamlContent(yamlContent: string): ArtifactGraph {
|
||||
const schema = parseSchema(yamlContent);
|
||||
return new ArtifactGraph(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ArtifactGraph from a pre-validated schema object.
|
||||
*/
|
||||
static fromSchema(schema: SchemaYaml): ArtifactGraph {
|
||||
return new ArtifactGraph(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single artifact by ID.
|
||||
*/
|
||||
getArtifact(id: string): Artifact | undefined {
|
||||
return this.artifacts.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all artifacts in the graph.
|
||||
*/
|
||||
getAllArtifacts(): Artifact[] {
|
||||
return Array.from(this.artifacts.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the schema name.
|
||||
*/
|
||||
getName(): string {
|
||||
return this.schema.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the schema version.
|
||||
*/
|
||||
getVersion(): number {
|
||||
return this.schema.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the topological build order using Kahn's algorithm.
|
||||
* Returns artifact IDs in the order they should be built.
|
||||
*/
|
||||
getBuildOrder(): string[] {
|
||||
const inDegree = new Map<string, number>();
|
||||
const dependents = new Map<string, string[]>();
|
||||
|
||||
// Initialize all artifacts
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
inDegree.set(artifact.id, artifact.requires.length);
|
||||
dependents.set(artifact.id, []);
|
||||
}
|
||||
|
||||
// Build reverse adjacency (who depends on whom)
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
for (const req of artifact.requires) {
|
||||
dependents.get(req)!.push(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Start with roots (in-degree 0), sorted for determinism
|
||||
const queue = [...this.artifacts.keys()]
|
||||
.filter(id => inDegree.get(id) === 0)
|
||||
.sort();
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
result.push(current);
|
||||
|
||||
// Collect newly ready artifacts, then sort before adding
|
||||
const newlyReady: string[] = [];
|
||||
for (const dep of dependents.get(current)!) {
|
||||
const newDegree = inDegree.get(dep)! - 1;
|
||||
inDegree.set(dep, newDegree);
|
||||
if (newDegree === 0) {
|
||||
newlyReady.push(dep);
|
||||
}
|
||||
}
|
||||
queue.push(...newlyReady.sort());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets artifacts that are ready to be created (all dependencies completed).
|
||||
*/
|
||||
getNextArtifacts(completed: CompletedSet): string[] {
|
||||
const ready: string[] = [];
|
||||
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
if (completed.has(artifact.id)) {
|
||||
continue; // Already completed
|
||||
}
|
||||
|
||||
const allDepsCompleted = artifact.requires.every(req => completed.has(req));
|
||||
if (allDepsCompleted) {
|
||||
ready.push(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for deterministic ordering
|
||||
return ready.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all artifacts in the graph are completed.
|
||||
*/
|
||||
isComplete(completed: CompletedSet): boolean {
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
if (!completed.has(artifact.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets blocked artifacts and their unmet dependencies.
|
||||
*/
|
||||
getBlocked(completed: CompletedSet): BlockedArtifacts {
|
||||
const blocked: BlockedArtifacts = {};
|
||||
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
if (completed.has(artifact.id)) {
|
||||
continue; // Already completed
|
||||
}
|
||||
|
||||
const unmetDeps = artifact.requires.filter(req => !completed.has(req));
|
||||
if (unmetDeps.length > 0) {
|
||||
blocked[artifact.id] = unmetDeps.sort();
|
||||
}
|
||||
}
|
||||
|
||||
return blocked;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Types
|
||||
export {
|
||||
ArtifactSchema,
|
||||
SchemaYamlSchema,
|
||||
type Artifact,
|
||||
type SchemaYaml,
|
||||
type CompletedSet,
|
||||
type BlockedArtifacts,
|
||||
} from './types.js';
|
||||
|
||||
// Schema loading and validation
|
||||
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
||||
|
||||
// Graph operations
|
||||
export { ArtifactGraph } from './graph.js';
|
||||
|
||||
// State detection
|
||||
export { detectCompleted } from './state.js';
|
||||
|
||||
// Schema resolution
|
||||
export {
|
||||
resolveSchema,
|
||||
listSchemas,
|
||||
getSchemaDir,
|
||||
getPackageSchemasDir,
|
||||
getUserSchemasDir,
|
||||
SchemaLoadError,
|
||||
} from './resolver.js';
|
||||
|
||||
// Instruction loading
|
||||
export {
|
||||
loadTemplate,
|
||||
loadChangeContext,
|
||||
generateInstructions,
|
||||
formatChangeStatus,
|
||||
TemplateLoadError,
|
||||
type ChangeContext,
|
||||
type ArtifactInstructions,
|
||||
type DependencyStatus,
|
||||
type ArtifactStatus,
|
||||
type ChangeStatus,
|
||||
} from './instruction-loader.js';
|
||||
@@ -1,269 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getSchemaDir, resolveSchema } from './resolver.js';
|
||||
import { ArtifactGraph } from './graph.js';
|
||||
import { detectCompleted } from './state.js';
|
||||
import type { Artifact, CompletedSet } from './types.js';
|
||||
|
||||
/**
|
||||
* Error thrown when loading a template fails.
|
||||
*/
|
||||
export class TemplateLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly templatePath: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TemplateLoadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change context containing graph, completion state, and metadata.
|
||||
*/
|
||||
export interface ChangeContext {
|
||||
/** The artifact dependency graph */
|
||||
graph: ArtifactGraph;
|
||||
/** Set of completed artifact IDs */
|
||||
completed: CompletedSet;
|
||||
/** Schema name being used */
|
||||
schemaName: string;
|
||||
/** Change name */
|
||||
changeName: string;
|
||||
/** Path to the change directory */
|
||||
changeDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriched instructions for creating an artifact.
|
||||
*/
|
||||
export interface ArtifactInstructions {
|
||||
/** Change name */
|
||||
changeName: string;
|
||||
/** Artifact ID */
|
||||
artifactId: string;
|
||||
/** Schema name */
|
||||
schemaName: string;
|
||||
/** Output path pattern (e.g., "proposal.md") */
|
||||
outputPath: string;
|
||||
/** Artifact description */
|
||||
description: string;
|
||||
/** Template content */
|
||||
template: string;
|
||||
/** Dependencies with completion status */
|
||||
dependencies: DependencyStatus[];
|
||||
/** Artifacts that become available after completing this one */
|
||||
unlocks: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency status information.
|
||||
*/
|
||||
export interface DependencyStatus {
|
||||
/** Artifact ID */
|
||||
id: string;
|
||||
/** Whether the dependency is completed */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a single artifact in the workflow.
|
||||
*/
|
||||
export interface ArtifactStatus {
|
||||
/** Artifact ID */
|
||||
id: string;
|
||||
/** Output path pattern */
|
||||
outputPath: string;
|
||||
/** Status: done, ready, or blocked */
|
||||
status: 'done' | 'ready' | 'blocked';
|
||||
/** Missing dependencies (only for blocked) */
|
||||
missingDeps?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted change status.
|
||||
*/
|
||||
export interface ChangeStatus {
|
||||
/** Change name */
|
||||
changeName: string;
|
||||
/** Schema name */
|
||||
schemaName: string;
|
||||
/** Whether all artifacts are complete */
|
||||
isComplete: boolean;
|
||||
/** Status of each artifact */
|
||||
artifacts: ArtifactStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a template from a schema's templates directory.
|
||||
*
|
||||
* @param schemaName - Schema name (e.g., "spec-driven")
|
||||
* @param templatePath - Relative path within the templates directory (e.g., "proposal.md")
|
||||
* @returns The template content
|
||||
* @throws TemplateLoadError if the template cannot be loaded
|
||||
*/
|
||||
export function loadTemplate(schemaName: string, templatePath: string): string {
|
||||
const schemaDir = getSchemaDir(schemaName);
|
||||
if (!schemaDir) {
|
||||
throw new TemplateLoadError(
|
||||
`Schema '${schemaName}' not found`,
|
||||
templatePath
|
||||
);
|
||||
}
|
||||
|
||||
const fullPath = path.join(schemaDir, 'templates', templatePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new TemplateLoadError(
|
||||
`Template not found: ${fullPath}`,
|
||||
fullPath
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
} catch (err) {
|
||||
const ioError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new TemplateLoadError(
|
||||
`Failed to read template: ${ioError.message}`,
|
||||
fullPath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads change context combining graph and completion state.
|
||||
*
|
||||
* @param projectRoot - Project root directory
|
||||
* @param changeName - Change name
|
||||
* @param schemaName - Optional schema name (defaults to "spec-driven")
|
||||
* @returns Change context with graph, completed set, and metadata
|
||||
*/
|
||||
export function loadChangeContext(
|
||||
projectRoot: string,
|
||||
changeName: string,
|
||||
schemaName: string = 'spec-driven'
|
||||
): ChangeContext {
|
||||
const schema = resolveSchema(schemaName);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
|
||||
const completed = detectCompleted(graph, changeDir);
|
||||
|
||||
return {
|
||||
graph,
|
||||
completed,
|
||||
schemaName,
|
||||
changeName,
|
||||
changeDir,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates enriched instructions for creating an artifact.
|
||||
*
|
||||
* @param context - Change context
|
||||
* @param artifactId - Artifact ID to generate instructions for
|
||||
* @returns Enriched artifact instructions
|
||||
* @throws Error if artifact not found
|
||||
*/
|
||||
export function generateInstructions(
|
||||
context: ChangeContext,
|
||||
artifactId: string
|
||||
): ArtifactInstructions {
|
||||
const artifact = context.graph.getArtifact(artifactId);
|
||||
if (!artifact) {
|
||||
throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);
|
||||
}
|
||||
|
||||
const template = loadTemplate(context.schemaName, artifact.template);
|
||||
const dependencies = getDependencyStatus(artifact, context.completed);
|
||||
const unlocks = getUnlockedArtifacts(context.graph, artifactId);
|
||||
|
||||
return {
|
||||
changeName: context.changeName,
|
||||
artifactId: artifact.id,
|
||||
schemaName: context.schemaName,
|
||||
outputPath: artifact.generates,
|
||||
description: artifact.description,
|
||||
template,
|
||||
dependencies,
|
||||
unlocks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets dependency status for an artifact.
|
||||
*/
|
||||
function getDependencyStatus(
|
||||
artifact: Artifact,
|
||||
completed: CompletedSet
|
||||
): DependencyStatus[] {
|
||||
return artifact.requires.map(id => ({
|
||||
id,
|
||||
done: completed.has(id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets artifacts that become available after completing the given artifact.
|
||||
*/
|
||||
function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[] {
|
||||
const unlocks: string[] = [];
|
||||
|
||||
for (const artifact of graph.getAllArtifacts()) {
|
||||
if (artifact.requires.includes(artifactId)) {
|
||||
unlocks.push(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
return unlocks.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the status of all artifacts in a change.
|
||||
*
|
||||
* @param context - Change context
|
||||
* @returns Formatted change status
|
||||
*/
|
||||
export function formatChangeStatus(context: ChangeContext): ChangeStatus {
|
||||
const artifacts = context.graph.getAllArtifacts();
|
||||
const ready = new Set(context.graph.getNextArtifacts(context.completed));
|
||||
const blocked = context.graph.getBlocked(context.completed);
|
||||
|
||||
const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => {
|
||||
if (context.completed.has(artifact.id)) {
|
||||
return {
|
||||
id: artifact.id,
|
||||
outputPath: artifact.generates,
|
||||
status: 'done' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.has(artifact.id)) {
|
||||
return {
|
||||
id: artifact.id,
|
||||
outputPath: artifact.generates,
|
||||
status: 'ready' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: artifact.id,
|
||||
outputPath: artifact.generates,
|
||||
status: 'blocked' as const,
|
||||
missingDeps: blocked[artifact.id] ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by build order for consistent output
|
||||
const buildOrder = context.graph.getBuildOrder();
|
||||
const orderMap = new Map(buildOrder.map((id, idx) => [id, idx]));
|
||||
artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||
|
||||
return {
|
||||
changeName: context.changeName,
|
||||
schemaName: context.schemaName,
|
||||
isComplete: context.graph.isComplete(context.completed),
|
||||
artifacts: artifactStatuses,
|
||||
};
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getGlobalDataDir } from '../global-config.js';
|
||||
import { parseSchema, SchemaValidationError } from './schema.js';
|
||||
import type { SchemaYaml } from './types.js';
|
||||
|
||||
/**
|
||||
* Error thrown when loading a schema fails.
|
||||
*/
|
||||
export class SchemaLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly schemaPath: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SchemaLoadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the package's built-in schemas directory path.
|
||||
* Uses import.meta.url to resolve relative to the current module.
|
||||
*/
|
||||
export function getPackageSchemasDir(): string {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
// Navigate from dist/core/artifact-graph/ to package root's schemas/
|
||||
return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user's schema override directory path.
|
||||
*/
|
||||
export function getUserSchemasDir(): string {
|
||||
return path.join(getGlobalDataDir(), 'schemas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a schema name to its directory path.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
|
||||
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
|
||||
*
|
||||
* @param name - Schema name (e.g., "spec-driven")
|
||||
* @returns The path to the schema directory, or null if not found
|
||||
*/
|
||||
export function getSchemaDir(name: string): string | null {
|
||||
// 1. Check user override directory
|
||||
const userDir = path.join(getUserSchemasDir(), name);
|
||||
const userSchemaPath = path.join(userDir, 'schema.yaml');
|
||||
if (fs.existsSync(userSchemaPath)) {
|
||||
return userDir;
|
||||
}
|
||||
|
||||
// 2. Check package built-in directory
|
||||
const packageDir = path.join(getPackageSchemasDir(), name);
|
||||
const packageSchemaPath = path.join(packageDir, 'schema.yaml');
|
||||
if (fs.existsSync(packageSchemaPath)) {
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a schema name to a SchemaYaml object.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
|
||||
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
|
||||
*
|
||||
* @param name - Schema name (e.g., "spec-driven")
|
||||
* @returns The resolved schema object
|
||||
* @throws Error if schema is not found in any location
|
||||
*/
|
||||
export function resolveSchema(name: string): SchemaYaml {
|
||||
// Normalize name (remove .yaml extension if provided)
|
||||
const normalizedName = name.replace(/\.ya?ml$/, '');
|
||||
|
||||
const schemaDir = getSchemaDir(normalizedName);
|
||||
if (!schemaDir) {
|
||||
const availableSchemas = listSchemas();
|
||||
throw new Error(
|
||||
`Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const schemaPath = path.join(schemaDir, 'schema.yaml');
|
||||
|
||||
// Load and parse the schema
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(schemaPath, 'utf-8');
|
||||
} catch (err) {
|
||||
const ioError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new SchemaLoadError(
|
||||
`Failed to read schema at '${schemaPath}': ${ioError.message}`,
|
||||
schemaPath,
|
||||
ioError
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return parseSchema(content);
|
||||
} catch (err) {
|
||||
if (err instanceof SchemaValidationError) {
|
||||
throw new SchemaLoadError(
|
||||
`Invalid schema at '${schemaPath}': ${err.message}`,
|
||||
schemaPath,
|
||||
err
|
||||
);
|
||||
}
|
||||
const parseError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new SchemaLoadError(
|
||||
`Failed to parse schema at '${schemaPath}': ${parseError.message}`,
|
||||
schemaPath,
|
||||
parseError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available schema names.
|
||||
* Combines user override and package built-in schemas.
|
||||
*/
|
||||
export function listSchemas(): string[] {
|
||||
const schemas = new Set<string>();
|
||||
|
||||
// Add package built-in schemas
|
||||
const packageDir = getPackageSchemasDir();
|
||||
if (fs.existsSync(packageDir)) {
|
||||
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
schemas.add(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user override schemas (may override package schemas)
|
||||
const userDir = getUserSchemasDir();
|
||||
if (fs.existsSync(userDir)) {
|
||||
for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
schemas.add(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(schemas).sort();
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SchemaValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates an artifact schema from a YAML file.
|
||||
*/
|
||||
export function loadSchema(filePath: string): SchemaYaml {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return parseSchema(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates an artifact schema from YAML content.
|
||||
*/
|
||||
export function parseSchema(yamlContent: string): SchemaYaml {
|
||||
const parsed = parseYaml(yamlContent);
|
||||
|
||||
// Validate with Zod
|
||||
const result = SchemaYamlSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
throw new SchemaValidationError(`Invalid schema: ${errors}`);
|
||||
}
|
||||
|
||||
const schema = result.data;
|
||||
|
||||
// Check for duplicate artifact IDs
|
||||
validateNoDuplicateIds(schema.artifacts);
|
||||
|
||||
// Check that all requires references are valid
|
||||
validateRequiresReferences(schema.artifacts);
|
||||
|
||||
// Check for cycles
|
||||
validateNoCycles(schema.artifacts);
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that there are no duplicate artifact IDs.
|
||||
*/
|
||||
function validateNoDuplicateIds(artifacts: Artifact[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const artifact of artifacts) {
|
||||
if (seen.has(artifact.id)) {
|
||||
throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);
|
||||
}
|
||||
seen.add(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all `requires` references point to valid artifact IDs.
|
||||
*/
|
||||
function validateRequiresReferences(artifacts: Artifact[]): void {
|
||||
const validIds = new Set(artifacts.map(a => a.id));
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
for (const req of artifact.requires) {
|
||||
if (!validIds.has(req)) {
|
||||
throw new SchemaValidationError(
|
||||
`Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that there are no cyclic dependencies.
|
||||
* Uses DFS to detect cycles and reports the full cycle path.
|
||||
*/
|
||||
function validateNoCycles(artifacts: Artifact[]): void {
|
||||
const artifactMap = new Map(artifacts.map(a => [a.id, a]));
|
||||
const visited = new Set<string>();
|
||||
const inStack = new Set<string>();
|
||||
const parent = new Map<string, string>();
|
||||
|
||||
function dfs(id: string): string | null {
|
||||
visited.add(id);
|
||||
inStack.add(id);
|
||||
|
||||
const artifact = artifactMap.get(id);
|
||||
if (!artifact) return null;
|
||||
|
||||
for (const dep of artifact.requires) {
|
||||
if (!visited.has(dep)) {
|
||||
parent.set(dep, id);
|
||||
const cycle = dfs(dep);
|
||||
if (cycle) return cycle;
|
||||
} else if (inStack.has(dep)) {
|
||||
// Found a cycle - reconstruct the path
|
||||
const cyclePath = [dep];
|
||||
let current = id;
|
||||
while (current !== dep) {
|
||||
cyclePath.unshift(current);
|
||||
current = parent.get(current)!;
|
||||
}
|
||||
cyclePath.unshift(dep);
|
||||
return cyclePath.join(' → ');
|
||||
}
|
||||
}
|
||||
|
||||
inStack.delete(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
if (!visited.has(artifact.id)) {
|
||||
const cycle = dfs(artifact.id);
|
||||
if (cycle) {
|
||||
throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import type { CompletedSet } from './types.js';
|
||||
import type { ArtifactGraph } from './graph.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
/**
|
||||
* Detects which artifacts are completed by checking file existence in the change directory.
|
||||
* Returns a Set of completed artifact IDs.
|
||||
*
|
||||
* @param graph - The artifact graph to check
|
||||
* @param changeDir - The change directory to scan for files
|
||||
* @returns Set of artifact IDs whose generated files exist
|
||||
*/
|
||||
export function detectCompleted(graph: ArtifactGraph, changeDir: string): CompletedSet {
|
||||
const completed = new Set<string>();
|
||||
|
||||
// Handle missing change directory gracefully
|
||||
if (!fs.existsSync(changeDir)) {
|
||||
return completed;
|
||||
}
|
||||
|
||||
for (const artifact of graph.getAllArtifacts()) {
|
||||
if (isArtifactComplete(artifact.generates, changeDir)) {
|
||||
completed.add(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
return completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an artifact is complete by checking if its generated file(s) exist.
|
||||
* Supports both simple paths and glob patterns.
|
||||
*/
|
||||
function isArtifactComplete(generates: string, changeDir: string): boolean {
|
||||
const fullPattern = path.join(changeDir, generates);
|
||||
|
||||
// Check if it's a glob pattern
|
||||
if (isGlobPattern(generates)) {
|
||||
return hasGlobMatches(fullPattern);
|
||||
}
|
||||
|
||||
// Simple file path - check if file exists
|
||||
return fs.existsSync(fullPattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path contains glob pattern characters.
|
||||
*/
|
||||
function isGlobPattern(pattern: string): boolean {
|
||||
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a glob pattern has any matches.
|
||||
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
|
||||
*/
|
||||
function hasGlobMatches(pattern: string): boolean {
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
|
||||
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
|
||||
return matches.length > 0;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Artifact definition schema
|
||||
export const ArtifactSchema = z.object({
|
||||
id: z.string().min(1, { error: 'Artifact ID is required' }),
|
||||
generates: z.string().min(1, { error: 'generates field is required' }),
|
||||
description: z.string(),
|
||||
template: z.string().min(1, { error: 'template field is required' }),
|
||||
requires: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
// Full schema YAML structure
|
||||
export const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1, { error: 'Schema name is required' }),
|
||||
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
|
||||
});
|
||||
|
||||
// Derived TypeScript types
|
||||
export type Artifact = z.infer<typeof ArtifactSchema>;
|
||||
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
|
||||
|
||||
// Runtime state types (not Zod - internal only)
|
||||
|
||||
// Slice 1: Simple completion tracking via filesystem
|
||||
export type CompletedSet = Set<string>;
|
||||
|
||||
// Return type for blocked query
|
||||
export interface BlockedArtifacts {
|
||||
[artifactId: string]: string[];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from 'path';
|
||||
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
||||
import { ChangeParser } from '../parsers/change-parser.js';
|
||||
import { Spec, Change } from '../schemas/index.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
export class JsonConverter {
|
||||
convertSpecToJson(filePath: string): string {
|
||||
@@ -44,7 +43,7 @@ export class JsonConverter {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as os from 'node:os';
|
||||
// Constants
|
||||
export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
|
||||
export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
|
||||
export const GLOBAL_DATA_DIR_NAME = 'openspec';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface GlobalConfig {
|
||||
@@ -46,37 +45,6 @@ export function getGlobalConfigDir(): string {
|
||||
return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the global data directory path following XDG Base Directory Specification.
|
||||
* Used for user data like schema overrides.
|
||||
*
|
||||
* - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set
|
||||
* - Unix/macOS fallback: ~/.local/share/openspec/
|
||||
* - Windows fallback: %LOCALAPPDATA%/openspec/
|
||||
*/
|
||||
export function getGlobalDataDir(): string {
|
||||
// XDG_DATA_HOME takes precedence on all platforms when explicitly set
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME;
|
||||
if (xdgDataHome) {
|
||||
return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Windows: use %LOCALAPPDATA%
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (localAppData) {
|
||||
return path.join(localAppData, GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
// Fallback for Windows if LOCALAPPDATA is not set
|
||||
return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
// Unix/macOS fallback: ~/.local/share
|
||||
return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the global config file.
|
||||
*/
|
||||
|
||||
+1
-3
@@ -2,11 +2,9 @@
|
||||
export {
|
||||
GLOBAL_CONFIG_DIR_NAME,
|
||||
GLOBAL_CONFIG_FILE_NAME,
|
||||
GLOBAL_DATA_DIR_NAME,
|
||||
type GlobalConfig,
|
||||
getGlobalConfigDir,
|
||||
getGlobalConfigPath,
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
getGlobalDataDir
|
||||
saveGlobalConfig
|
||||
} from './global-config.js';
|
||||
+1
-1
@@ -107,7 +107,7 @@ export class UpdateCommand {
|
||||
|
||||
if (updatedSlashFiles.length > 0) {
|
||||
// Normalize to forward slashes for cross-platform log consistency
|
||||
const normalized = updatedSlashFiles.map((p) => FileSystemUtils.toPosixPath(p));
|
||||
const normalized = updatedSlashFiles.map((p) => p.replace(/\\/g, '/'));
|
||||
summaryParts.push(`Updated slash commands: ${normalized.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,12 @@ import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';
|
||||
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
||||
import { ChangeParser } from '../parsers/change-parser.js';
|
||||
import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';
|
||||
import {
|
||||
import {
|
||||
MIN_PURPOSE_LENGTH,
|
||||
MAX_REQUIREMENT_TEXT_LENGTH,
|
||||
VALIDATION_MESSAGES
|
||||
VALIDATION_MESSAGES
|
||||
} from './constants.js';
|
||||
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
export class Validator {
|
||||
private strictMode: boolean;
|
||||
@@ -360,7 +359,7 @@ export class Validator {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
// Look for the directory name after 'specs' or 'changes'
|
||||
|
||||
+23
-53
@@ -23,26 +23,16 @@ export class ViewCommand {
|
||||
// Display summary metrics
|
||||
this.displaySummary(changesData, specsData);
|
||||
|
||||
// Display draft changes
|
||||
if (changesData.draft.length > 0) {
|
||||
console.log(chalk.bold.gray('\nDraft Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.draft.forEach((change) => {
|
||||
console.log(` ${chalk.gray('○')} ${change.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Display active changes
|
||||
if (changesData.active.length > 0) {
|
||||
console.log(chalk.bold.cyan('\nActive Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.active.forEach((change) => {
|
||||
changesData.active.forEach(change => {
|
||||
const progressBar = this.createProgressBar(change.progress.completed, change.progress.total);
|
||||
const percentage =
|
||||
change.progress.total > 0
|
||||
? Math.round((change.progress.completed / change.progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
const percentage = change.progress.total > 0
|
||||
? Math.round((change.progress.completed / change.progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
console.log(
|
||||
` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}`
|
||||
);
|
||||
@@ -53,7 +43,7 @@ export class ViewCommand {
|
||||
if (changesData.completed.length > 0) {
|
||||
console.log(chalk.bold.green('\nCompleted Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.completed.forEach((change) => {
|
||||
changesData.completed.forEach(change => {
|
||||
console.log(` ${chalk.green('✓')} ${change.name}`);
|
||||
});
|
||||
}
|
||||
@@ -79,43 +69,33 @@ export class ViewCommand {
|
||||
}
|
||||
|
||||
private async getChangesData(openspecDir: string): Promise<{
|
||||
draft: Array<{ name: string }>;
|
||||
active: Array<{ name: string; progress: { total: number; completed: number } }>;
|
||||
completed: Array<{ name: string }>;
|
||||
}> {
|
||||
const changesDir = path.join(openspecDir, 'changes');
|
||||
|
||||
|
||||
if (!fs.existsSync(changesDir)) {
|
||||
return { draft: [], active: [], completed: [] };
|
||||
return { active: [], completed: [] };
|
||||
}
|
||||
|
||||
const draft: Array<{ name: string }> = [];
|
||||
const active: Array<{ name: string; progress: { total: number; completed: number } }> = [];
|
||||
const completed: Array<{ name: string }> = [];
|
||||
|
||||
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
|
||||
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== 'archive') {
|
||||
const progress = await getTaskProgressForChange(changesDir, entry.name);
|
||||
|
||||
if (progress.total === 0) {
|
||||
// No tasks defined yet - still in planning/draft phase
|
||||
draft.push({ name: entry.name });
|
||||
} else if (progress.completed === progress.total) {
|
||||
// All tasks complete
|
||||
|
||||
if (progress.total === 0 || progress.completed === progress.total) {
|
||||
completed.push({ name: entry.name });
|
||||
} else {
|
||||
// Has tasks but not all complete
|
||||
active.push({ name: entry.name, progress });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all categories by name for deterministic ordering
|
||||
draft.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Sort active changes by completion percentage (ascending) and then by name
|
||||
// Sort active changes by completion percentage (ascending) and then by name for deterministic ordering
|
||||
active.sort((a, b) => {
|
||||
const percentageA = a.progress.total > 0 ? a.progress.completed / a.progress.total : 0;
|
||||
const percentageB = b.progress.total > 0 ? b.progress.completed / b.progress.total : 0;
|
||||
@@ -126,7 +106,7 @@ export class ViewCommand {
|
||||
});
|
||||
completed.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { draft, active, completed };
|
||||
return { active, completed };
|
||||
}
|
||||
|
||||
private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
|
||||
@@ -162,45 +142,35 @@ export class ViewCommand {
|
||||
}
|
||||
|
||||
private displaySummary(
|
||||
changesData: { draft: any[]; active: any[]; completed: any[] },
|
||||
changesData: { active: any[]; completed: any[] },
|
||||
specsData: any[]
|
||||
): void {
|
||||
const totalChanges =
|
||||
changesData.draft.length + changesData.active.length + changesData.completed.length;
|
||||
const totalChanges = changesData.active.length + changesData.completed.length;
|
||||
const totalSpecs = specsData.length;
|
||||
const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0);
|
||||
|
||||
|
||||
// Calculate total task progress
|
||||
let totalTasks = 0;
|
||||
let completedTasks = 0;
|
||||
|
||||
changesData.active.forEach((change) => {
|
||||
|
||||
changesData.active.forEach(change => {
|
||||
totalTasks += change.progress.total;
|
||||
completedTasks += change.progress.completed;
|
||||
});
|
||||
|
||||
|
||||
changesData.completed.forEach(() => {
|
||||
// Completed changes count as 100% done (we don't know exact task count)
|
||||
// This is a simplification
|
||||
});
|
||||
|
||||
console.log(chalk.bold('Summary:'));
|
||||
console.log(
|
||||
` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`
|
||||
);
|
||||
if (changesData.draft.length > 0) {
|
||||
console.log(` ${chalk.gray('●')} Draft Changes: ${chalk.bold(changesData.draft.length)}`);
|
||||
}
|
||||
console.log(
|
||||
` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`
|
||||
);
|
||||
console.log(` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`);
|
||||
console.log(` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`);
|
||||
console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
|
||||
|
||||
|
||||
if (totalTasks > 0) {
|
||||
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
|
||||
console.log(
|
||||
` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`
|
||||
);
|
||||
console.log(` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import path from 'path';
|
||||
import { FileSystemUtils } from './file-system.js';
|
||||
|
||||
/**
|
||||
* Result of validating a change name.
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a change name follows kebab-case conventions.
|
||||
*
|
||||
* Valid names:
|
||||
* - Start with a lowercase letter
|
||||
* - Contain only lowercase letters, numbers, and hyphens
|
||||
* - Do not start or end with a hyphen
|
||||
* - Do not contain consecutive hyphens
|
||||
*
|
||||
* @param name - The change name to validate
|
||||
* @returns Validation result with `valid: true` or `valid: false` with an error message
|
||||
*
|
||||
* @example
|
||||
* validateChangeName('add-auth') // { valid: true }
|
||||
* validateChangeName('Add-Auth') // { valid: false, error: '...' }
|
||||
*/
|
||||
export function validateChangeName(name: string): ValidationResult {
|
||||
// Pattern: starts with lowercase letter, followed by lowercase letters/numbers,
|
||||
// optionally followed by hyphen + lowercase letters/numbers (repeatable)
|
||||
const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
|
||||
if (!name) {
|
||||
return { valid: false, error: 'Change name cannot be empty' };
|
||||
}
|
||||
|
||||
if (!kebabCasePattern.test(name)) {
|
||||
// Provide specific error messages for common mistakes
|
||||
if (/[A-Z]/.test(name)) {
|
||||
return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' };
|
||||
}
|
||||
if (/_/.test(name)) {
|
||||
return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' };
|
||||
}
|
||||
if (name.startsWith('-')) {
|
||||
return { valid: false, error: 'Change name cannot start with a hyphen' };
|
||||
}
|
||||
if (name.endsWith('-')) {
|
||||
return { valid: false, error: 'Change name cannot end with a hyphen' };
|
||||
}
|
||||
if (/--/.test(name)) {
|
||||
return { valid: false, error: 'Change name cannot contain consecutive hyphens' };
|
||||
}
|
||||
if (/[^a-z0-9-]/.test(name)) {
|
||||
return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };
|
||||
}
|
||||
if (/^[0-9]/.test(name)) {
|
||||
return { valid: false, error: 'Change name must start with a letter' };
|
||||
}
|
||||
|
||||
return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new change directory.
|
||||
*
|
||||
* @param projectRoot - The root directory of the project (where `openspec/` lives)
|
||||
* @param name - The change name (must be valid kebab-case)
|
||||
* @throws Error if the change name is invalid
|
||||
* @throws Error if the change directory already exists
|
||||
*
|
||||
* @example
|
||||
* // Creates openspec/changes/add-auth/
|
||||
* await createChange('/path/to/project', 'add-auth')
|
||||
*/
|
||||
export async function createChange(
|
||||
projectRoot: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
// Validate the name first
|
||||
const validation = validateChangeName(name);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
// Build the change directory path
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
|
||||
|
||||
// Check if change already exists
|
||||
if (await FileSystemUtils.directoryExists(changeDir)) {
|
||||
throw new Error(`Change '${name}' already exists at ${changeDir}`);
|
||||
}
|
||||
|
||||
// Create the directory (including parent directories if needed)
|
||||
await FileSystemUtils.createDirectory(changeDir);
|
||||
}
|
||||
@@ -42,14 +42,6 @@ function findMarkerIndex(
|
||||
}
|
||||
|
||||
export class FileSystemUtils {
|
||||
/**
|
||||
* Converts a path to use forward slashes (POSIX style).
|
||||
* Essential for cross-platform compatibility with glob libraries like fast-glob.
|
||||
*/
|
||||
static toPosixPath(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
private static isWindowsBasePath(basePath: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,3 +1,2 @@
|
||||
// Shared utilities
|
||||
export { validateChangeName, createChange } from './change-utils.js';
|
||||
export type { ValidationResult } from './change-utils.js';
|
||||
// Shared utilities will be implemented here
|
||||
export {};
|
||||
@@ -1,444 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
|
||||
describe('artifact-workflow CLI commands', () => {
|
||||
let tempDir: string;
|
||||
let changesDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));
|
||||
changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets combined output from CLI result (ora outputs to stdout).
|
||||
*/
|
||||
function getOutput(result: { stdout: string; stderr: string }): string {
|
||||
return result.stdout + result.stderr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test change with the specified artifacts completed.
|
||||
* Note: An "active" change requires at least a proposal.md file to be detected.
|
||||
* If no artifacts are specified, we create an empty proposal to make it detectable.
|
||||
*/
|
||||
async function createTestChange(
|
||||
changeName: string,
|
||||
artifacts: ('proposal' | 'design' | 'specs' | 'tasks')[] = []
|
||||
): Promise<string> {
|
||||
const changeDir = path.join(changesDir, changeName);
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
// Always create proposal.md for the change to be detected as active
|
||||
// Content varies based on whether 'proposal' is in artifacts list
|
||||
const proposalContent = artifacts.includes('proposal')
|
||||
? '## Why\nTest proposal content that is long enough.\n\n## What Changes\n- **test:** Something'
|
||||
: '## Why\nMinimal proposal.\n\n## What Changes\n- **test:** Placeholder';
|
||||
await fs.writeFile(path.join(changeDir, 'proposal.md'), proposalContent);
|
||||
|
||||
if (artifacts.includes('design')) {
|
||||
await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\n\nTechnical design.');
|
||||
}
|
||||
|
||||
if (artifacts.includes('specs')) {
|
||||
// specs artifact uses glob pattern "specs/*.md" - files directly in specs/ directory
|
||||
const specsDir = path.join(changeDir, 'specs');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\nTest spec.');
|
||||
}
|
||||
|
||||
if (artifacts.includes('tasks')) {
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '## Tasks\n- [ ] Task 1');
|
||||
}
|
||||
|
||||
return changeDir;
|
||||
}
|
||||
|
||||
describe('status command', () => {
|
||||
it('shows status for scaffolded change without proposal.md', async () => {
|
||||
// Create empty change directory (no proposal.md)
|
||||
const changeDir = path.join(changesDir, 'scaffolded-change');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
const result = await runCLI(['status', '--change', 'scaffolded-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('scaffolded-change');
|
||||
expect(result.stdout).toContain('0/4 artifacts complete');
|
||||
});
|
||||
|
||||
it('shows status for a change with proposal only', async () => {
|
||||
// createTestChange always creates proposal.md, so this has 1 artifact complete
|
||||
await createTestChange('minimal-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'minimal-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('minimal-change');
|
||||
expect(result.stdout).toContain('spec-driven');
|
||||
expect(result.stdout).toContain('1/4 artifacts complete');
|
||||
});
|
||||
|
||||
it('shows status for a change with proposal and design', async () => {
|
||||
await createTestChange('partial-change', ['proposal', 'design']);
|
||||
|
||||
const result = await runCLI(['status', '--change', 'partial-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('2/4 artifacts complete');
|
||||
expect(result.stdout).toContain('[x]');
|
||||
});
|
||||
|
||||
it('outputs JSON when --json flag is used', async () => {
|
||||
await createTestChange('json-change', ['proposal', 'design']);
|
||||
|
||||
const result = await runCLI(['status', '--change', 'json-change', '--json'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changeName).toBe('json-change');
|
||||
expect(json.schemaName).toBe('spec-driven');
|
||||
expect(json.isComplete).toBe(false);
|
||||
expect(Array.isArray(json.artifacts)).toBe(true);
|
||||
expect(json.artifacts).toHaveLength(4);
|
||||
|
||||
const proposalArtifact = json.artifacts.find((a: any) => a.id === 'proposal');
|
||||
expect(proposalArtifact.status).toBe('done');
|
||||
});
|
||||
|
||||
it('shows complete status when all artifacts are done', async () => {
|
||||
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
|
||||
|
||||
const result = await runCLI(['status', '--change', 'complete-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('4/4 artifacts complete');
|
||||
expect(result.stdout).toContain('All artifacts complete!');
|
||||
});
|
||||
|
||||
it('errors when --change is missing and lists available changes', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(['status'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Missing required option --change');
|
||||
expect(output).toContain('some-change');
|
||||
});
|
||||
|
||||
it('errors for unknown change name and lists available changes', async () => {
|
||||
await createTestChange('existing-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'nonexistent'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Change 'nonexistent' not found");
|
||||
expect(output).toContain('existing-change');
|
||||
});
|
||||
|
||||
it('supports --schema option', async () => {
|
||||
await createTestChange('tdd-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'tdd-change', '--schema', 'tdd'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('tdd');
|
||||
});
|
||||
|
||||
it('errors for unknown schema', async () => {
|
||||
await createTestChange('test-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'test-change', '--schema', 'unknown'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Schema 'unknown' not found");
|
||||
});
|
||||
|
||||
it('rejects path traversal in change name', async () => {
|
||||
const result = await runCLI(['status', '--change', '../foo'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Invalid change name');
|
||||
});
|
||||
|
||||
it('rejects absolute path in change name', async () => {
|
||||
const result = await runCLI(['status', '--change', '/etc/passwd'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Invalid change name');
|
||||
});
|
||||
|
||||
it('rejects slashes in change name', async () => {
|
||||
const result = await runCLI(['status', '--change', 'foo/bar'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Invalid change name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('next command', () => {
|
||||
it('shows proposal as next for scaffolded change', async () => {
|
||||
// Create empty change directory (no proposal.md)
|
||||
const changeDir = path.join(changesDir, 'scaffolded-change');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
const result = await runCLI(['next', '--change', 'scaffolded-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifacts ready to create');
|
||||
expect(result.stdout).toContain('proposal');
|
||||
});
|
||||
|
||||
it('shows design and specs as next when proposal exists', async () => {
|
||||
// createTestChange always creates proposal.md, so design and specs are ready
|
||||
await createTestChange('minimal-change');
|
||||
|
||||
const result = await runCLI(['next', '--change', 'minimal-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifacts ready to create');
|
||||
expect(result.stdout).toContain('design');
|
||||
expect(result.stdout).toContain('specs');
|
||||
});
|
||||
|
||||
it('shows tasks as next after proposal, design, and specs', async () => {
|
||||
await createTestChange('after-specs', ['proposal', 'design', 'specs']);
|
||||
|
||||
const result = await runCLI(['next', '--change', 'after-specs'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('tasks');
|
||||
});
|
||||
|
||||
it('shows complete message when all done', async () => {
|
||||
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
|
||||
|
||||
const result = await runCLI(['next', '--change', 'complete-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('All artifacts are complete!');
|
||||
});
|
||||
|
||||
it('outputs JSON array of ready artifacts', async () => {
|
||||
await createTestChange('json-next', ['proposal']);
|
||||
|
||||
const result = await runCLI(['next', '--change', 'json-next', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(json)).toBe(true);
|
||||
expect(json).toContain('design');
|
||||
expect(json).toContain('specs');
|
||||
});
|
||||
|
||||
it('errors when --change is missing and lists available changes', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(['next'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Missing required option --change');
|
||||
expect(output).toContain('some-change');
|
||||
});
|
||||
});
|
||||
|
||||
describe('instructions command', () => {
|
||||
it('shows instructions for proposal on scaffolded change', async () => {
|
||||
// Create empty change directory (no proposal.md)
|
||||
const changeDir = path.join(changesDir, 'scaffolded-change');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
const result = await runCLI(['instructions', 'proposal', '--change', 'scaffolded-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifact: proposal');
|
||||
expect(result.stdout).toContain('proposal.md');
|
||||
expect(result.stdout).toContain('Template:');
|
||||
});
|
||||
|
||||
it('shows instructions for design artifact', async () => {
|
||||
await createTestChange('instr-change');
|
||||
|
||||
const result = await runCLI(['instructions', 'design', '--change', 'instr-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifact: design');
|
||||
expect(result.stdout).toContain('design.md');
|
||||
expect(result.stdout).toContain('Template:');
|
||||
});
|
||||
|
||||
it('shows blocked warning for artifact with unmet dependencies', async () => {
|
||||
// tasks depends on design and specs, which are not done yet
|
||||
await createTestChange('blocked-change');
|
||||
|
||||
const result = await runCLI(['instructions', 'tasks', '--change', 'blocked-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Warning: This artifact has unmet dependencies');
|
||||
expect(result.stdout).toContain('[missing]');
|
||||
});
|
||||
|
||||
it('outputs JSON for instructions', async () => {
|
||||
await createTestChange('json-instr', ['proposal']);
|
||||
|
||||
const result = await runCLI(['instructions', 'design', '--change', 'json-instr', '--json'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.artifactId).toBe('design');
|
||||
expect(json.outputPath).toContain('design.md');
|
||||
expect(typeof json.template).toBe('string');
|
||||
expect(Array.isArray(json.dependencies)).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when artifact argument is missing', async () => {
|
||||
await createTestChange('test-change');
|
||||
|
||||
const result = await runCLI(['instructions', '--change', 'test-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Missing required argument <artifact>');
|
||||
expect(output).toContain('Valid artifacts');
|
||||
});
|
||||
|
||||
it('errors for unknown artifact', async () => {
|
||||
await createTestChange('test-change');
|
||||
|
||||
const result = await runCLI(['instructions', 'unknown-artifact', '--change', 'test-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Artifact 'unknown-artifact' not found");
|
||||
expect(output).toContain('Valid artifacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('templates command', () => {
|
||||
it('shows template paths for default schema', async () => {
|
||||
const result = await runCLI(['templates'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Schema: spec-driven');
|
||||
expect(result.stdout).toContain('proposal:');
|
||||
expect(result.stdout).toContain('design:');
|
||||
expect(result.stdout).toContain('specs:');
|
||||
expect(result.stdout).toContain('tasks:');
|
||||
});
|
||||
|
||||
it('shows template paths for custom schema', async () => {
|
||||
const result = await runCLI(['templates', '--schema', 'tdd'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Schema: tdd');
|
||||
expect(result.stdout).toContain('spec:');
|
||||
expect(result.stdout).toContain('tests:');
|
||||
});
|
||||
|
||||
it('outputs JSON mapping of templates', async () => {
|
||||
const result = await runCLI(['templates', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.proposal).toBeDefined();
|
||||
expect(json.proposal.path).toContain('proposal.md');
|
||||
expect(json.proposal.source).toBe('package');
|
||||
});
|
||||
|
||||
it('errors for unknown schema', async () => {
|
||||
const result = await runCLI(['templates', '--schema', 'nonexistent'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Schema 'nonexistent' not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe('new change command', () => {
|
||||
it('creates a new change directory', async () => {
|
||||
const result = await runCLI(['new', 'change', 'my-new-feature'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Created change 'my-new-feature'");
|
||||
|
||||
const changeDir = path.join(changesDir, 'my-new-feature');
|
||||
const stat = await fs.stat(changeDir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('creates README.md when --description is provided', async () => {
|
||||
const result = await runCLI(
|
||||
['new', 'change', 'described-feature', '--description', 'This is a test feature'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const readmePath = path.join(changesDir, 'described-feature', 'README.md');
|
||||
const content = await fs.readFile(readmePath, 'utf-8');
|
||||
expect(content).toContain('described-feature');
|
||||
expect(content).toContain('This is a test feature');
|
||||
});
|
||||
|
||||
it('errors for invalid change name with spaces', async () => {
|
||||
const result = await runCLI(['new', 'change', 'invalid name'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Error');
|
||||
});
|
||||
|
||||
it('errors for duplicate change name', async () => {
|
||||
await createTestChange('existing-change');
|
||||
|
||||
const result = await runCLI(['new', 'change', 'existing-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('exists');
|
||||
});
|
||||
|
||||
it('errors when name argument is missing', async () => {
|
||||
const result = await runCLI(['new', 'change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('help text', () => {
|
||||
it('marks status command as experimental in help', async () => {
|
||||
const result = await runCLI(['status', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks next command as experimental in help', async () => {
|
||||
const result = await runCLI(['next', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks instructions command as experimental in help', async () => {
|
||||
const result = await runCLI(['instructions', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks templates command as experimental in help', async () => {
|
||||
const result = await runCLI(['templates', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks new command as experimental in help', async () => {
|
||||
const result = await runCLI(['new', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -127,133 +127,6 @@ Then expected result happens`;
|
||||
expect(updatedContent).toContain('#### Scenario: Basic test');
|
||||
});
|
||||
|
||||
it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => {
|
||||
const changeName = 'new-spec-with-removed';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'gift-card');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create delta spec with both ADDED and REMOVED requirements
|
||||
// This simulates refactoring where old fields are removed and new ones are added
|
||||
const specContent = `# Gift Card - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Logo and Background Color
|
||||
The system SHALL support logo and backgroundColor fields for gift cards.
|
||||
|
||||
#### Scenario: Display gift card with logo
|
||||
- **WHEN** a gift card is displayed
|
||||
- **THEN** it shows the logo and backgroundColor
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Image Field
|
||||
### Requirement: Thumbnail Field`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive - should succeed with warning about REMOVED requirements
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
// Verify warning was logged about REMOVED requirements being ignored
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).')
|
||||
);
|
||||
|
||||
// Verify spec was created with only ADDED requirements
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md');
|
||||
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
|
||||
expect(updatedContent).toContain('# gift-card Specification');
|
||||
expect(updatedContent).toContain('### Requirement: Logo and Background Color');
|
||||
expect(updatedContent).toContain('#### Scenario: Display gift card with logo');
|
||||
// REMOVED requirements should not be in the final spec
|
||||
expect(updatedContent).not.toContain('### Requirement: Image Field');
|
||||
expect(updatedContent).not.toContain('### Requirement: Thumbnail Field');
|
||||
|
||||
// Verify change was archived successfully
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.length).toBeGreaterThan(0);
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(true);
|
||||
});
|
||||
|
||||
it('should still error on MODIFIED when creating new spec file', async () => {
|
||||
const changeName = 'new-spec-with-modified';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'new-capability');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create delta spec with MODIFIED requirement (should fail for new spec)
|
||||
const specContent = `# New Capability - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
New feature description.
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
Modified content.`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive - should abort with error message (not throw, but log and return)
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
// Verify error message mentions MODIFIED not allowed for new specs
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('new-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
|
||||
|
||||
// Verify spec was NOT created
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md');
|
||||
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
||||
|
||||
// Verify change was NOT archived
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(false);
|
||||
});
|
||||
|
||||
it('should still error on RENAMED when creating new spec file', async () => {
|
||||
const changeName = 'new-spec-with-renamed';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'another-capability');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create delta spec with RENAMED requirement (should fail for new spec)
|
||||
const specContent = `# Another Capability - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
New feature description.
|
||||
|
||||
## RENAMED Requirements
|
||||
- FROM: \`### Requirement: Old Name\`
|
||||
- TO: \`### Requirement: New Name\``;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive - should abort with error message (not throw, but log and return)
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
// Verify error message mentions RENAMED not allowed for new specs
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('another-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
|
||||
|
||||
// Verify spec was NOT created
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md');
|
||||
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
||||
|
||||
// Verify change was NOT archived
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if change does not exist', async () => {
|
||||
await expect(
|
||||
archiveCommand.execute('non-existent-change', { yes: true })
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
|
||||
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
|
||||
|
||||
describe('artifact-graph/graph', () => {
|
||||
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
|
||||
name: 'test',
|
||||
version: 1,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
describe('fromSchema', () => {
|
||||
it('should create graph from schema object', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getName()).toBe('test');
|
||||
expect(graph.getVersion()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromYamlContent', () => {
|
||||
it('should create graph from YAML string', () => {
|
||||
const yaml = `
|
||||
name: my-workflow
|
||||
version: 2
|
||||
artifacts:
|
||||
- id: doc
|
||||
generates: doc.md
|
||||
description: Documentation
|
||||
template: templates/doc.md
|
||||
`;
|
||||
const graph = ArtifactGraph.fromYamlContent(yaml);
|
||||
|
||||
expect(graph.getName()).toBe('my-workflow');
|
||||
expect(graph.getVersion()).toBe(2);
|
||||
expect(graph.getArtifact('doc')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArtifact', () => {
|
||||
it('should return artifact by ID', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const artifact = graph.getArtifact('proposal');
|
||||
|
||||
expect(artifact).toBeDefined();
|
||||
expect(artifact?.id).toBe('proposal');
|
||||
expect(artifact?.generates).toBe('proposal.md');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent ID', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getArtifact('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllArtifacts', () => {
|
||||
it('should return all artifacts', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const artifacts = graph.getAllArtifacts();
|
||||
|
||||
expect(artifacts).toHaveLength(3);
|
||||
expect(artifacts.map(a => a.id).sort()).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildOrder', () => {
|
||||
it('should return correct order for linear chain A → B → C', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['B'] },
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order = graph.getBuildOrder();
|
||||
|
||||
expect(order).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('should handle diamond dependency correctly', () => {
|
||||
// A → B, A → C, B → D, C → D
|
||||
const schema = createSchema([
|
||||
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order = graph.getBuildOrder();
|
||||
|
||||
// A must come before B and C; D must come last
|
||||
expect(order.indexOf('A')).toBeLessThan(order.indexOf('B'));
|
||||
expect(order.indexOf('A')).toBeLessThan(order.indexOf('C'));
|
||||
expect(order.indexOf('B')).toBeLessThan(order.indexOf('D'));
|
||||
expect(order.indexOf('C')).toBeLessThan(order.indexOf('D'));
|
||||
});
|
||||
|
||||
it('should return independent artifacts in stable sorted order', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'Z', generates: 'z.md', description: 'Z', template: 't.md', requires: [] },
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'M', generates: 'm.md', description: 'M', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order = graph.getBuildOrder();
|
||||
|
||||
// All independent, should be sorted alphabetically for stability
|
||||
expect(order).toEqual(['A', 'M', 'Z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextArtifacts', () => {
|
||||
it('should return root artifacts when nothing completed', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const ready = graph.getNextArtifacts(new Set());
|
||||
|
||||
expect(ready.sort()).toEqual(['A', 'C']);
|
||||
});
|
||||
|
||||
it('should include artifact when all deps completed', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const ready = graph.getNextArtifacts(new Set(['A']));
|
||||
|
||||
expect(ready).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('should not include completed artifacts', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const ready = graph.getNextArtifacts(new Set(['A', 'B']));
|
||||
|
||||
expect(ready).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle diamond dependency correctly', () => {
|
||||
// D requires B and C
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
|
||||
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Only A completed - B and C ready, D not
|
||||
expect(graph.getNextArtifacts(new Set(['A'])).sort()).toEqual(['B', 'C']);
|
||||
|
||||
// Only B completed (from deps) - C still needed for D
|
||||
expect(graph.getNextArtifacts(new Set(['A', 'B']))).toEqual(['C']);
|
||||
|
||||
// Both B and C completed - D ready
|
||||
expect(graph.getNextArtifacts(new Set(['A', 'B', 'C']))).toEqual(['D']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isComplete', () => {
|
||||
it('should return true when all artifacts completed', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.isComplete(new Set(['A', 'B']))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when some artifacts incomplete', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.isComplete(new Set(['A']))).toBe(false);
|
||||
expect(graph.isComplete(new Set())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlocked', () => {
|
||||
it('should return empty object when nothing is blocked', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getBlocked(new Set())).toEqual({});
|
||||
});
|
||||
|
||||
it('should return artifact blocked by single dependency', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getBlocked(new Set())).toEqual({ B: ['A'] });
|
||||
});
|
||||
|
||||
it('should return artifact blocked by multiple dependencies', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Neither A nor B completed
|
||||
expect(graph.getBlocked(new Set())).toEqual({ C: ['A', 'B'] });
|
||||
});
|
||||
|
||||
it('should only list unmet dependencies', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// A completed, B not
|
||||
expect(graph.getBlocked(new Set(['A']))).toEqual({ C: ['B'] });
|
||||
});
|
||||
|
||||
it('should not include completed artifacts', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getBlocked(new Set(['A', 'B']))).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
loadTemplate,
|
||||
loadChangeContext,
|
||||
generateInstructions,
|
||||
formatChangeStatus,
|
||||
TemplateLoadError,
|
||||
} from '../../../src/core/artifact-graph/instruction-loader.js';
|
||||
|
||||
describe('instruction-loader', () => {
|
||||
describe('loadTemplate', () => {
|
||||
it('should load template from schema directory', () => {
|
||||
// Uses built-in spec-driven schema
|
||||
const template = loadTemplate('spec-driven', 'proposal.md');
|
||||
|
||||
expect(template).toContain('## Why');
|
||||
expect(template).toContain('## What Changes');
|
||||
});
|
||||
|
||||
it('should throw TemplateLoadError for non-existent template', () => {
|
||||
expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow(
|
||||
TemplateLoadError
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw TemplateLoadError for non-existent schema', () => {
|
||||
expect(() => loadTemplate('nonexistent-schema', 'proposal.md')).toThrow(
|
||||
TemplateLoadError
|
||||
);
|
||||
});
|
||||
|
||||
it('should include template path in error', () => {
|
||||
try {
|
||||
loadTemplate('spec-driven', 'nonexistent.md');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TemplateLoadError);
|
||||
expect((err as TemplateLoadError).templatePath).toContain('nonexistent.md');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadChangeContext', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load context with default schema', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
|
||||
expect(context.schemaName).toBe('spec-driven');
|
||||
expect(context.changeName).toBe('my-change');
|
||||
expect(context.graph.getName()).toBe('spec-driven');
|
||||
expect(context.completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should load context with custom schema', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change', 'tdd');
|
||||
|
||||
expect(context.schemaName).toBe('tdd');
|
||||
expect(context.graph.getName()).toBe('tdd');
|
||||
});
|
||||
|
||||
it('should detect completed artifacts', () => {
|
||||
// Create change directory with proposal.md
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
|
||||
expect(context.completed.has('proposal')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty completed set for non-existent change directory', () => {
|
||||
const context = loadChangeContext(tempDir, 'nonexistent-change');
|
||||
|
||||
expect(context.completed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateInstructions', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should include artifact metadata', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
expect(instructions.changeName).toBe('my-change');
|
||||
expect(instructions.artifactId).toBe('proposal');
|
||||
expect(instructions.schemaName).toBe('spec-driven');
|
||||
expect(instructions.outputPath).toBe('proposal.md');
|
||||
});
|
||||
|
||||
it('should include template content', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
expect(instructions.template).toContain('## Why');
|
||||
});
|
||||
|
||||
it('should show dependencies with completion status', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'specs');
|
||||
|
||||
expect(instructions.dependencies).toHaveLength(1);
|
||||
expect(instructions.dependencies[0].id).toBe('proposal');
|
||||
expect(instructions.dependencies[0].done).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark completed dependencies as done', () => {
|
||||
// Create proposal
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'specs');
|
||||
|
||||
expect(instructions.dependencies[0].done).toBe(true);
|
||||
});
|
||||
|
||||
it('should list artifacts unlocked by this one', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
// proposal unlocks specs and design
|
||||
expect(instructions.unlocks).toContain('specs');
|
||||
expect(instructions.unlocks).toContain('design');
|
||||
});
|
||||
|
||||
it('should have empty dependencies for root artifact', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
expect(instructions.dependencies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw for non-existent artifact', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
|
||||
expect(() => generateInstructions(context, 'nonexistent')).toThrow(
|
||||
"Artifact 'nonexistent' not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatChangeStatus', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should show all artifacts as ready/blocked when nothing completed', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
expect(status.changeName).toBe('my-change');
|
||||
expect(status.schemaName).toBe('spec-driven');
|
||||
expect(status.isComplete).toBe(false);
|
||||
|
||||
// proposal has no deps, should be ready
|
||||
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
||||
expect(proposal?.status).toBe('ready');
|
||||
|
||||
// specs depends on proposal, should be blocked
|
||||
const specs = status.artifacts.find(a => a.id === 'specs');
|
||||
expect(specs?.status).toBe('blocked');
|
||||
expect(specs?.missingDeps).toContain('proposal');
|
||||
});
|
||||
|
||||
it('should show completed artifacts as done', () => {
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
||||
expect(proposal?.status).toBe('done');
|
||||
|
||||
// specs should now be ready
|
||||
const specs = status.artifacts.find(a => a.id === 'specs');
|
||||
expect(specs?.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should include output paths for each artifact', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
||||
expect(proposal?.outputPath).toBe('proposal.md');
|
||||
|
||||
const specs = status.artifacts.find(a => a.id === 'specs');
|
||||
expect(specs?.outputPath).toBe('specs/**/*.md');
|
||||
});
|
||||
|
||||
it('should report isComplete true when all done', () => {
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true });
|
||||
|
||||
// Create all required files for spec-driven schema
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
fs.writeFileSync(path.join(changeDir, 'specs', 'test.md'), '# Spec');
|
||||
fs.writeFileSync(path.join(changeDir, 'design.md'), '# Design');
|
||||
fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
expect(status.isComplete).toBe(true);
|
||||
expect(status.artifacts.every(a => a.status === 'done')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show blocked artifacts with missing dependencies', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
// tasks requires specs and design
|
||||
const tasks = status.artifacts.find(a => a.id === 'tasks');
|
||||
expect(tasks?.status).toBe('blocked');
|
||||
expect(tasks?.missingDeps).toContain('specs');
|
||||
expect(tasks?.missingDeps).toContain('design');
|
||||
});
|
||||
|
||||
it('should sort artifacts in build order', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
const ids = status.artifacts.map(a => a.id);
|
||||
const proposalIdx = ids.indexOf('proposal');
|
||||
const specsIdx = ids.indexOf('specs');
|
||||
const tasksIdx = ids.indexOf('tasks');
|
||||
|
||||
// proposal must come before specs, specs before tasks
|
||||
expect(proposalIdx).toBeLessThan(specsIdx);
|
||||
expect(specsIdx).toBeLessThan(tasksIdx);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,327 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
resolveSchema,
|
||||
listSchemas,
|
||||
SchemaLoadError,
|
||||
getSchemaDir,
|
||||
getPackageSchemasDir,
|
||||
getUserSchemasDir,
|
||||
} from '../../../src/core/artifact-graph/resolver.js';
|
||||
|
||||
describe('artifact-graph/resolver', () => {
|
||||
let tempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(os.tmpdir(), `openspec-resolver-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getPackageSchemasDir', () => {
|
||||
it('should return a valid path', () => {
|
||||
const schemasDir = getPackageSchemasDir();
|
||||
expect(typeof schemasDir).toBe('string');
|
||||
expect(schemasDir.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserSchemasDir', () => {
|
||||
it('should use XDG_DATA_HOME when set', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userDir = getUserSchemasDir();
|
||||
expect(userDir).toBe(path.join(tempDir, 'openspec', 'schemas'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSchemaDir', () => {
|
||||
it('should return null for non-existent schema', () => {
|
||||
const dir = getSchemaDir('nonexistent-schema');
|
||||
expect(dir).toBeNull();
|
||||
});
|
||||
|
||||
it('should return package dir for built-in schema', () => {
|
||||
const dir = getSchemaDir('spec-driven');
|
||||
expect(dir).not.toBeNull();
|
||||
expect(dir).toContain('schemas');
|
||||
expect(dir).toContain('spec-driven');
|
||||
});
|
||||
|
||||
it('should prefer user override directory', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(userSchemaDir, 'schema.yaml'),
|
||||
'name: custom\nversion: 1\nartifacts: []'
|
||||
);
|
||||
|
||||
const dir = getSchemaDir('spec-driven');
|
||||
expect(dir).toBe(userSchemaDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSchema', () => {
|
||||
it('should return built-in spec-driven schema', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('spec-driven');
|
||||
expect(schema.version).toBe(1);
|
||||
expect(schema.artifacts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return built-in tdd schema', () => {
|
||||
const schema = resolveSchema('tdd');
|
||||
|
||||
expect(schema.name).toBe('tdd');
|
||||
expect(schema.version).toBe(1);
|
||||
expect(schema.artifacts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should strip .yaml extension from name', () => {
|
||||
const schema1 = resolveSchema('spec-driven');
|
||||
const schema2 = resolveSchema('spec-driven.yaml');
|
||||
|
||||
expect(schema1).toEqual(schema2);
|
||||
});
|
||||
|
||||
it('should strip .yml extension from name', () => {
|
||||
const schema1 = resolveSchema('spec-driven');
|
||||
const schema2 = resolveSchema('spec-driven.yml');
|
||||
|
||||
expect(schema1).toEqual(schema2);
|
||||
});
|
||||
|
||||
it('should prefer user override over built-in', () => {
|
||||
// Set up global data dir
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a custom schema with same name as built-in
|
||||
const customSchema = `
|
||||
name: custom-override
|
||||
version: 99
|
||||
artifacts:
|
||||
- id: custom
|
||||
generates: custom.md
|
||||
description: Custom artifact
|
||||
template: custom.md
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), customSchema);
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('custom-override');
|
||||
expect(schema.version).toBe(99);
|
||||
});
|
||||
|
||||
it('should validate user override and throw on invalid schema', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create an invalid schema (missing required fields)
|
||||
const invalidSchema = `
|
||||
name: invalid
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: broken
|
||||
# missing generates, description, template
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError);
|
||||
});
|
||||
|
||||
it('should include file path in validation error message', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
const invalidSchema = `
|
||||
name: invalid
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: broken
|
||||
`;
|
||||
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
|
||||
fs.writeFileSync(schemaPath, invalidSchema);
|
||||
|
||||
try {
|
||||
resolveSchema('spec-driven');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as SchemaLoadError;
|
||||
expect(error.message).toContain(schemaPath);
|
||||
expect(error.schemaPath).toBe(schemaPath);
|
||||
expect(error.cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect cycles in user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a schema with cyclic dependencies
|
||||
const cyclicSchema = `
|
||||
name: cyclic
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: a
|
||||
generates: a.md
|
||||
description: A
|
||||
template: a.md
|
||||
requires: [b]
|
||||
- id: b
|
||||
generates: b.md
|
||||
description: B
|
||||
template: b.md
|
||||
requires: [a]
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), cyclicSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);
|
||||
});
|
||||
|
||||
it('should detect invalid requires references in user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a schema with invalid requires reference
|
||||
const invalidRefSchema = `
|
||||
name: invalid-ref
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: a
|
||||
generates: a.md
|
||||
description: A
|
||||
template: a.md
|
||||
requires: [nonexistent]
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidRefSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
it('should throw SchemaLoadError on YAML syntax errors', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create malformed YAML
|
||||
const malformedYaml = `
|
||||
name: bad
|
||||
version: [[[invalid yaml
|
||||
`;
|
||||
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
|
||||
fs.writeFileSync(schemaPath, malformedYaml);
|
||||
|
||||
try {
|
||||
resolveSchema('spec-driven');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SchemaLoadError);
|
||||
const error = e as SchemaLoadError;
|
||||
expect(error.message).toContain('Failed to parse');
|
||||
expect(error.message).toContain(schemaPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to built-in when user override not found', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
// Don't create any user schemas
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('spec-driven');
|
||||
expect(schema.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw when schema not found', () => {
|
||||
expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('should list available schemas in error message', () => {
|
||||
try {
|
||||
resolveSchema('nonexistent');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
expect(error.message).toContain('spec-driven');
|
||||
expect(error.message).toContain('tdd');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSchemas', () => {
|
||||
it('should list built-in schemas', () => {
|
||||
const schemas = listSchemas();
|
||||
|
||||
expect(schemas).toContain('spec-driven');
|
||||
expect(schemas).toContain('tdd');
|
||||
});
|
||||
|
||||
it('should include user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'custom-workflow');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []');
|
||||
|
||||
const schemas = listSchemas();
|
||||
|
||||
expect(schemas).toContain('custom-workflow');
|
||||
expect(schemas).toContain('spec-driven');
|
||||
});
|
||||
|
||||
it('should deduplicate schemas with same name', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
// Override spec-driven
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []');
|
||||
|
||||
const schemas = listSchemas();
|
||||
|
||||
// Should only appear once
|
||||
const count = schemas.filter(s => s === 'spec-driven').length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('should return sorted list', () => {
|
||||
const schemas = listSchemas();
|
||||
|
||||
const sorted = [...schemas].sort();
|
||||
expect(schemas).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('should only include directories with schema.yaml', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemasBase = path.join(tempDir, 'openspec', 'schemas');
|
||||
|
||||
// Create a directory without schema.yaml
|
||||
const emptyDir = path.join(userSchemasBase, 'empty-dir');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
// Create a valid schema directory
|
||||
const validDir = path.join(userSchemasBase, 'valid-schema');
|
||||
fs.mkdirSync(validDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(validDir, 'schema.yaml'), 'name: valid\nversion: 1\nartifacts: []');
|
||||
|
||||
const schemas = listSchemas();
|
||||
|
||||
expect(schemas).toContain('valid-schema');
|
||||
expect(schemas).not.toContain('empty-dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSchema, SchemaValidationError } from '../../../src/core/artifact-graph/schema.js';
|
||||
|
||||
describe('artifact-graph/schema', () => {
|
||||
describe('parseSchema', () => {
|
||||
it('should parse valid schema YAML', () => {
|
||||
const yaml = `
|
||||
name: test-schema
|
||||
version: 1
|
||||
description: A test schema
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Initial proposal
|
||||
template: templates/proposal.md
|
||||
requires: []
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: Design document
|
||||
template: templates/design.md
|
||||
requires:
|
||||
- proposal
|
||||
`;
|
||||
const schema = parseSchema(yaml);
|
||||
|
||||
expect(schema.name).toBe('test-schema');
|
||||
expect(schema.version).toBe(1);
|
||||
expect(schema.description).toBe('A test schema');
|
||||
expect(schema.artifacts).toHaveLength(2);
|
||||
expect(schema.artifacts[0].id).toBe('proposal');
|
||||
expect(schema.artifacts[1].requires).toEqual(['proposal']);
|
||||
});
|
||||
|
||||
it('should throw on missing required fields', () => {
|
||||
const yaml = `
|
||||
name: test-schema
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: proposal
|
||||
description: Missing generates and template
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/generates/);
|
||||
});
|
||||
|
||||
it('should throw on missing schema name', () => {
|
||||
const yaml = `
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Test
|
||||
template: templates/proposal.md
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/name/);
|
||||
});
|
||||
|
||||
it('should throw on invalid version (non-positive)', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 0
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Test
|
||||
template: templates/proposal.md
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/positive/);
|
||||
});
|
||||
|
||||
it('should throw on empty artifacts array', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts: []
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/artifact/i);
|
||||
});
|
||||
|
||||
it('should throw on duplicate artifact IDs', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: First
|
||||
template: templates/proposal.md
|
||||
- id: proposal
|
||||
generates: other.md
|
||||
description: Duplicate
|
||||
template: templates/other.md
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Duplicate artifact ID: proposal/);
|
||||
});
|
||||
|
||||
it('should throw on invalid requires reference', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: Design doc
|
||||
template: templates/design.md
|
||||
requires:
|
||||
- nonexistent
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Invalid dependency reference.*nonexistent/);
|
||||
});
|
||||
|
||||
it('should detect self-referencing cycle', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: A
|
||||
generates: a.md
|
||||
description: Self reference
|
||||
template: templates/a.md
|
||||
requires:
|
||||
- A
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
|
||||
});
|
||||
|
||||
it('should detect simple A → B → A cycle', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: A
|
||||
generates: a.md
|
||||
description: A
|
||||
template: templates/a.md
|
||||
requires:
|
||||
- B
|
||||
- id: B
|
||||
generates: b.md
|
||||
description: B
|
||||
template: templates/b.md
|
||||
requires:
|
||||
- A
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
|
||||
expect(() => parseSchema(yaml)).toThrow(/→/);
|
||||
});
|
||||
|
||||
it('should detect longer A → B → C → A cycle and list all IDs', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: A
|
||||
generates: a.md
|
||||
description: A
|
||||
template: templates/a.md
|
||||
requires:
|
||||
- C
|
||||
- id: B
|
||||
generates: b.md
|
||||
description: B
|
||||
template: templates/b.md
|
||||
requires:
|
||||
- A
|
||||
- id: C
|
||||
generates: c.md
|
||||
description: C
|
||||
template: templates/c.md
|
||||
requires:
|
||||
- B
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
|
||||
// Should contain all three in the cycle path
|
||||
const error = (() => {
|
||||
try {
|
||||
parseSchema(yaml);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})() as Error;
|
||||
expect(error.message).toMatch(/A.*→.*B|B.*→.*C|C.*→.*A/);
|
||||
});
|
||||
|
||||
it('should allow default empty requires array', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: root
|
||||
generates: root.md
|
||||
description: Root artifact
|
||||
template: templates/root.md
|
||||
`;
|
||||
const schema = parseSchema(yaml);
|
||||
expect(schema.artifacts[0].requires).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
|
||||
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
|
||||
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
|
||||
|
||||
describe('artifact-graph/state', () => {
|
||||
let tempDir: string;
|
||||
|
||||
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
|
||||
name: 'test',
|
||||
version: 1,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(os.tmpdir(), `openspec-state-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('detectCompleted', () => {
|
||||
it('should return empty set when changeDir does not exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const completed = detectCompleted(graph, '/nonexistent/path');
|
||||
|
||||
expect(completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty set when changeDir is empty', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should mark artifact complete when file exists', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create the file
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('proposal')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not mark artifact complete when file does not exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Only create proposal.md
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('proposal')).toBe(true);
|
||||
expect(completed.has('design')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested paths', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'nested', generates: 'docs/design.md', description: 'Nested', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create nested directory and file
|
||||
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'docs', 'design.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('nested')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect glob pattern as complete when files exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create specs directory with files
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-a.md'), 'content');
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-b.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not mark glob pattern complete when directory is empty', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create empty specs directory
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not mark glob pattern complete when directory does not exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not mark glob pattern complete when only non-matching files exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create specs directory with non-matching files
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'readme.txt'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts with mixed completion', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: ['proposal'] },
|
||||
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: ['proposal'] },
|
||||
{ id: 'tasks', generates: 'tasks.md', description: 'Tasks', template: 't.md', requires: ['specs', 'design'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create some files
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'auth.md'), 'content');
|
||||
// design.md and tasks.md do not exist
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('proposal')).toBe(true);
|
||||
expect(completed.has('specs')).toBe(true);
|
||||
expect(completed.has('design')).toBe(false);
|
||||
expect(completed.has('tasks')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { resolveSchema } from '../../../src/core/artifact-graph/resolver.js';
|
||||
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
|
||||
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
|
||||
import type { BlockedArtifacts } from '../../../src/core/artifact-graph/types.js';
|
||||
|
||||
/**
|
||||
* Normalize BlockedArtifacts for comparison by sorting dependency arrays.
|
||||
* The order of unmet dependencies is not guaranteed, so we sort for stable assertions.
|
||||
*/
|
||||
function normalizeBlocked(blocked: BlockedArtifacts): BlockedArtifacts {
|
||||
const normalized: BlockedArtifacts = {};
|
||||
for (const [key, deps] of Object.entries(blocked)) {
|
||||
normalized[key] = [...deps].sort();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
describe('artifact-graph workflow integration', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use a unique temp directory for each test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workflow-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory after each test
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('spec-driven workflow', () => {
|
||||
it('should progress through complete workflow', () => {
|
||||
// 1. Resolve the real built-in schema
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Verify schema structure
|
||||
expect(graph.getName()).toBe('spec-driven');
|
||||
expect(graph.getAllArtifacts()).toHaveLength(4);
|
||||
|
||||
// 2. Initial state - nothing complete, only proposal is ready
|
||||
let completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.size).toBe(0);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
|
||||
expect(graph.isComplete(completed)).toBe(false);
|
||||
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
|
||||
specs: ['proposal'],
|
||||
design: ['proposal'],
|
||||
tasks: ['design', 'specs'],
|
||||
});
|
||||
|
||||
// 3. Create proposal.md - now specs and design become ready
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal\n\nInitial proposal content.');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal']));
|
||||
expect(graph.getNextArtifacts(completed).sort()).toEqual(['design', 'specs']);
|
||||
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
|
||||
tasks: ['design', 'specs'],
|
||||
});
|
||||
|
||||
// 4. Create design.md - specs still needed for tasks
|
||||
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design\n\nTechnical design content.');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design']));
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
|
||||
expect(graph.getBlocked(completed)).toEqual({
|
||||
tasks: ['specs'],
|
||||
});
|
||||
|
||||
// 5. Create specs directory with a spec file - tasks becomes ready
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(specsDir, 'feature-auth.md'), '# Auth Spec\n\nAuthentication specification.');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design', 'specs']));
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['tasks']);
|
||||
expect(graph.getBlocked(completed)).toEqual({});
|
||||
|
||||
// 6. Create tasks.md - workflow complete
|
||||
fs.writeFileSync(path.join(tempDir, 'tasks.md'), '# Tasks\n\n- [ ] Implement feature');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design', 'specs', 'tasks']));
|
||||
expect(graph.getNextArtifacts(completed)).toEqual([]);
|
||||
expect(graph.isComplete(completed)).toBe(true);
|
||||
expect(graph.getBlocked(completed)).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle out-of-order file creation', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create files in wrong order - design before proposal
|
||||
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design');
|
||||
|
||||
let completed = detectCompleted(graph, tempDir);
|
||||
// design file exists but it's still marked complete (filesystem-based)
|
||||
expect(completed).toEqual(new Set(['design']));
|
||||
// proposal is still the only "ready" artifact since it has no deps
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
|
||||
|
||||
// Now create proposal
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design']));
|
||||
// specs is the only thing ready now (design already done)
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
|
||||
});
|
||||
|
||||
it('should handle multiple spec files in glob pattern', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Complete prerequisites
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
// Create specs directory with multiple files
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(specsDir, 'auth.md'), '# Auth');
|
||||
fs.writeFileSync(path.join(specsDir, 'api.md'), '# API');
|
||||
fs.writeFileSync(path.join(specsDir, 'database.md'), '# Database');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.has('specs')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tdd workflow', () => {
|
||||
it('should progress through complete workflow', () => {
|
||||
const schema = resolveSchema('tdd');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getName()).toBe('tdd');
|
||||
expect(graph.getBuildOrder()).toEqual(['spec', 'tests', 'implementation', 'docs']);
|
||||
|
||||
// Initial state
|
||||
let completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['spec']);
|
||||
|
||||
// Create spec
|
||||
fs.writeFileSync(path.join(tempDir, 'spec.md'), '# Feature Spec');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['tests']);
|
||||
|
||||
// Create tests directory with test file
|
||||
const testsDir = path.join(tempDir, 'tests');
|
||||
fs.mkdirSync(testsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(testsDir, 'feature.test.ts'), 'describe("feature", () => {});');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['implementation']);
|
||||
|
||||
// Create src directory with implementation
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'feature.ts'), 'export function feature() {}');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['docs']);
|
||||
|
||||
// Create docs
|
||||
const docsDir = path.join(tempDir, 'docs');
|
||||
fs.mkdirSync(docsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(docsDir, 'feature.md'), '# Feature Documentation');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.isComplete(completed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('build order consistency', () => {
|
||||
it('should return consistent build order across multiple calls', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order1 = graph.getBuildOrder();
|
||||
const order2 = graph.getBuildOrder();
|
||||
const order3 = graph.getBuildOrder();
|
||||
|
||||
expect(order1).toEqual(order2);
|
||||
expect(order2).toEqual(order3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty and edge cases', () => {
|
||||
it('should handle empty change directory gracefully', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Directory exists but is empty
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.size).toBe(0);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
|
||||
});
|
||||
|
||||
it('should handle non-existent change directory', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const nonExistentDir = path.join(tempDir, 'does-not-exist');
|
||||
const completed = detectCompleted(graph, nonExistentDir);
|
||||
expect(completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should not count non-matching files in glob directories', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create specs directory with wrong file types
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(specsDir, 'notes.txt'), 'not a markdown file');
|
||||
fs.writeFileSync(path.join(specsDir, 'data.json'), '{}');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,56 +28,6 @@ describe('ViewCommand', () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('shows changes with no tasks in Draft section, not Completed', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
// Empty change (no tasks.md) - should show in Draft
|
||||
await fs.mkdir(path.join(changesDir, 'empty-change'), { recursive: true });
|
||||
|
||||
// Change with tasks.md but no tasks - should show in Draft
|
||||
await fs.mkdir(path.join(changesDir, 'no-tasks-change'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'no-tasks-change', 'tasks.md'), '# Tasks\n\nNo tasks yet.');
|
||||
|
||||
// Change with all tasks complete - should show in Completed
|
||||
await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(changesDir, 'completed-change', 'tasks.md'),
|
||||
'- [x] Done task\n'
|
||||
);
|
||||
|
||||
const viewCommand = new ViewCommand();
|
||||
await viewCommand.execute(tempDir);
|
||||
|
||||
const output = logOutput.map(stripAnsi).join('\n');
|
||||
|
||||
// Draft section should contain empty and no-tasks changes
|
||||
expect(output).toContain('Draft Changes');
|
||||
expect(output).toContain('empty-change');
|
||||
expect(output).toContain('no-tasks-change');
|
||||
|
||||
// Completed section should only contain changes with all tasks done
|
||||
expect(output).toContain('Completed Changes');
|
||||
expect(output).toContain('completed-change');
|
||||
|
||||
// Verify empty-change and no-tasks-change are in Draft section (marked with ○)
|
||||
const draftLines = logOutput
|
||||
.map(stripAnsi)
|
||||
.filter((line) => line.includes('○'));
|
||||
const draftNames = draftLines.map((line) => line.trim().replace('○ ', ''));
|
||||
expect(draftNames).toContain('empty-change');
|
||||
expect(draftNames).toContain('no-tasks-change');
|
||||
|
||||
// Verify completed-change is in Completed section (marked with ✓)
|
||||
const completedLines = logOutput
|
||||
.map(stripAnsi)
|
||||
.filter((line) => line.includes('✓'));
|
||||
const completedNames = completedLines.map((line) => line.trim().replace('✓ ', ''));
|
||||
expect(completedNames).toContain('completed-change');
|
||||
expect(completedNames).not.toContain('empty-change');
|
||||
expect(completedNames).not.toContain('no-tasks-change');
|
||||
});
|
||||
|
||||
it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
@@ -90,9 +90,6 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
// Prevent child process from keeping the event loop alive
|
||||
child.unref();
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
@@ -116,19 +113,11 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
// Explicitly destroy streams to prevent hanging handles
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
child.stdin?.destroy();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
// Explicitly destroy streams to prevent hanging handles
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
child.stdin?.destroy();
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { validateChangeName, createChange } from '../../src/utils/change-utils.js';
|
||||
|
||||
describe('validateChangeName', () => {
|
||||
describe('valid names', () => {
|
||||
it('should accept simple kebab-case name', () => {
|
||||
const result = validateChangeName('add-auth');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept name with multiple segments', () => {
|
||||
const result = validateChangeName('add-user-auth');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept name with numeric suffix', () => {
|
||||
const result = validateChangeName('add-feature-2');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept single word name', () => {
|
||||
const result = validateChangeName('refactor');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept name with numbers in segments', () => {
|
||||
const result = validateChangeName('upgrade-to-v2');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - uppercase rejected', () => {
|
||||
it('should reject name with uppercase letters', () => {
|
||||
const result = validateChangeName('Add-Auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('lowercase');
|
||||
});
|
||||
|
||||
it('should reject fully uppercase name', () => {
|
||||
const result = validateChangeName('ADD-AUTH');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('lowercase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - spaces rejected', () => {
|
||||
it('should reject name with spaces', () => {
|
||||
const result = validateChangeName('add auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('spaces');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - underscores rejected', () => {
|
||||
it('should reject name with underscores', () => {
|
||||
const result = validateChangeName('add_auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('underscores');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - special characters rejected', () => {
|
||||
it('should reject name with exclamation mark', () => {
|
||||
const result = validateChangeName('add-auth!');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject name with @ symbol', () => {
|
||||
const result = validateChangeName('add@auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - leading/trailing hyphens rejected', () => {
|
||||
it('should reject name with leading hyphen', () => {
|
||||
const result = validateChangeName('-add-auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('start with a hyphen');
|
||||
});
|
||||
|
||||
it('should reject name with trailing hyphen', () => {
|
||||
const result = validateChangeName('add-auth-');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('end with a hyphen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - consecutive hyphens rejected', () => {
|
||||
it('should reject name with double hyphens', () => {
|
||||
const result = validateChangeName('add--auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('consecutive hyphens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - empty name rejected', () => {
|
||||
it('should reject empty string', () => {
|
||||
const result = validateChangeName('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('empty');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChange', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('creates directory', () => {
|
||||
it('should create change directory', async () => {
|
||||
await createChange(testDir, 'add-auth');
|
||||
|
||||
const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth');
|
||||
const stats = await fs.stat(changeDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate change throws error', () => {
|
||||
it('should throw error if change already exists', async () => {
|
||||
await createChange(testDir, 'add-auth');
|
||||
|
||||
await expect(createChange(testDir, 'add-auth')).rejects.toThrow(
|
||||
/already exists/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid name throws validation error', () => {
|
||||
it('should throw error for uppercase name', async () => {
|
||||
await expect(createChange(testDir, 'Add-Auth')).rejects.toThrow(
|
||||
/lowercase/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for name with spaces', async () => {
|
||||
await expect(createChange(testDir, 'add auth')).rejects.toThrow(
|
||||
/spaces/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty name', async () => {
|
||||
await expect(createChange(testDir, '')).rejects.toThrow(
|
||||
/empty/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('creates parent directories if needed', () => {
|
||||
it('should create openspec/changes/ directories if they do not exist', async () => {
|
||||
const newProjectDir = path.join(testDir, 'new-project');
|
||||
await fs.mkdir(newProjectDir);
|
||||
|
||||
// openspec/changes/ does not exist yet
|
||||
await createChange(newProjectDir, 'add-auth');
|
||||
|
||||
const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth');
|
||||
const stats = await fs.stat(changeDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-2
@@ -20,7 +20,6 @@ export default defineConfig({
|
||||
]
|
||||
},
|
||||
testTimeout: 10000,
|
||||
hookTimeout: 10000,
|
||||
teardownTimeout: 3000
|
||||
hookTimeout: 10000
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,9 +4,3 @@ import { ensureCliBuilt } from './test/helpers/run-cli.js';
|
||||
export async function setup() {
|
||||
await ensureCliBuilt();
|
||||
}
|
||||
|
||||
// Global teardown to ensure clean exit
|
||||
export async function teardown() {
|
||||
// Clear any remaining timers
|
||||
// This helps prevent hanging handles from keeping the process alive
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user