mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c846421a | ||
|
|
702c163048 | ||
|
|
4f4af5708d | ||
|
|
9822576770 | ||
|
|
af273b8e0b | ||
|
|
3ceef2db72 | ||
|
|
2c2599b1f0 | ||
|
|
c08a53cb21 | ||
|
|
455c65f3c4 |
@@ -140,8 +140,6 @@ dist/
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Internal Docs
|
||||
docs/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @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
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
# 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>.yaml # Global user override
|
||||
2. <package>/schemas/<name>.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 also use 2-level resolution (to be implemented in Slice 3):
|
||||
|
||||
```
|
||||
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
|
||||
2. ${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
|
||||
3. <package>/templates/<artifact>.md # Built-in fallback
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Schema-specific templates override shared templates
|
||||
- Shared templates override package built-ins
|
||||
- A CLI command shows resolved paths (no guessing)
|
||||
- No inheritance between schemas (copy if you need to diverge)
|
||||
- Max 2 levels - no deeper inheritance chains
|
||||
|
||||
**Why this matters:**
|
||||
- Avoids "where does this come from?" debugging
|
||||
- No implicit magic that works until it doesn't
|
||||
- Clear boundaries between shared and specific
|
||||
|
||||
---
|
||||
|
||||
## 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>.yaml # User override
|
||||
↓ (not found)
|
||||
<package>/schemas/<name>.yaml # Built-in
|
||||
↓ (not found)
|
||||
Error (schema not found)
|
||||
```
|
||||
|
||||
### 4. Two-Level Template Fallback (Slice 3)
|
||||
|
||||
```
|
||||
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
|
||||
↓ (not found)
|
||||
${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
|
||||
↓ (not found)
|
||||
<package>/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.yaml # User-defined schema
|
||||
└── templates/ # Template overrides (Slice 3)
|
||||
└── proposal.md # Custom proposal template
|
||||
|
||||
# Package (built-in defaults)
|
||||
<package>/
|
||||
├── schemas/ # Built-in schema definitions
|
||||
│ ├── spec-driven.yaml # Default: proposal → specs → design → tasks
|
||||
│ └── tdd.yaml # TDD: tests → implementation → docs
|
||||
└── templates/ # Built-in templates (Slice 3)
|
||||
├── proposal.md
|
||||
├── design.md
|
||||
├── specs.md
|
||||
└── tasks.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.yaml
|
||||
# Or user override: ~/.local/share/openspec/schemas/spec-driven.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 via 2-level fallback (Slice 3)
|
||||
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
|
||||
@@ -0,0 +1,149 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,20 @@
|
||||
## 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`
|
||||
@@ -0,0 +1,65 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Template Loading
|
||||
The system SHALL load templates from schema directories.
|
||||
|
||||
#### Scenario: Template loaded from schema directory
|
||||
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
|
||||
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
|
||||
|
||||
#### Scenario: Template 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 missing change
|
||||
- **WHEN** `loadChangeContext` is called for a non-existent change directory
|
||||
- **THEN** the system returns context with empty completed set
|
||||
|
||||
### Requirement: Instruction Enrichment
|
||||
The system SHALL enrich templates with change-specific context.
|
||||
|
||||
#### Scenario: Header with change info
|
||||
- **WHEN** instructions are generated for an artifact
|
||||
- **THEN** the output includes change name, artifact ID, schema name, and output path
|
||||
|
||||
#### Scenario: Dependency status shown
|
||||
- **WHEN** an artifact has dependencies
|
||||
- **THEN** the output shows each dependency with completion status (done/missing)
|
||||
|
||||
#### Scenario: Next steps shown
|
||||
- **WHEN** instructions are generated
|
||||
- **THEN** the output includes which artifacts become available after this one
|
||||
|
||||
#### Scenario: Root artifact dependencies
|
||||
- **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: Format complete change
|
||||
- **WHEN** all artifacts are completed
|
||||
- **THEN** status shows all artifacts as "done"
|
||||
|
||||
#### Scenario: Format partial change
|
||||
- **WHEN** some artifacts are completed
|
||||
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
|
||||
|
||||
#### Scenario: Show blocked dependencies
|
||||
- **WHEN** an artifact is blocked
|
||||
- **THEN** status shows which dependencies are missing
|
||||
|
||||
#### Scenario: Show output paths
|
||||
- **WHEN** status is formatted
|
||||
- **THEN** each artifact shows its output path pattern
|
||||
@@ -0,0 +1,34 @@
|
||||
## 1. Template Loading
|
||||
|
||||
- [ ] 1.1 Create `src/core/artifact-graph/template.ts`
|
||||
- [ ] 1.2 Implement `loadTemplate(schemaName, templatePath)` using schema directory structure
|
||||
- [ ] 1.3 Add tests for template loading from schema directory
|
||||
- [ ] 1.4 Add tests for error when template not found
|
||||
|
||||
## 2. Change Context
|
||||
|
||||
- [ ] 2.1 Create `src/core/artifact-graph/context.ts`
|
||||
- [ ] 2.2 Define `ChangeContext` interface
|
||||
- [ ] 2.3 Implement `loadChangeContext()` function
|
||||
- [ ] 2.4 Add tests for context loading with existing change
|
||||
- [ ] 2.5 Add tests for context loading with missing change directory
|
||||
|
||||
## 3. Instruction Enrichment
|
||||
|
||||
- [ ] 3.1 Create `src/core/artifact-graph/instructions.ts`
|
||||
- [ ] 3.2 Implement `getInstructions()` with header injection
|
||||
- [ ] 3.3 Add dependency status formatting (done/missing)
|
||||
- [ ] 3.4 Add next steps calculation
|
||||
- [ ] 3.5 Add tests for enrichment output
|
||||
|
||||
## 4. Status Formatting
|
||||
|
||||
- [ ] 4.1 Implement `formatStatus()` function in instructions.ts
|
||||
- [ ] 4.2 Format as markdown table with status and output path
|
||||
- [ ] 4.3 Show blocked dependencies
|
||||
- [ ] 4.4 Add tests for status formatting
|
||||
|
||||
## 5. Integration
|
||||
|
||||
- [ ] 5.1 Export new functions from `src/core/artifact-graph/index.ts`
|
||||
- [ ] 5.2 Ensure all tests pass
|
||||
@@ -0,0 +1,197 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,18 @@
|
||||
## 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
@@ -0,0 +1,103 @@
|
||||
## 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'] }`
|
||||
@@ -0,0 +1,61 @@
|
||||
## 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
|
||||
@@ -0,0 +1,74 @@
|
||||
## 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 |
|
||||
@@ -0,0 +1,45 @@
|
||||
## 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
|
||||
@@ -0,0 +1,63 @@
|
||||
## 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: "..." }`
|
||||
@@ -0,0 +1,30 @@
|
||||
## 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
|
||||
@@ -0,0 +1,129 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,20 @@
|
||||
## 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`)
|
||||
@@ -0,0 +1,49 @@
|
||||
## 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
|
||||
@@ -0,0 +1,32 @@
|
||||
## 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
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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.
|
||||
|
||||
#### 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'] }`
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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: "..." }`
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.17.1",
|
||||
"version": "0.17.2",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -73,7 +73,9 @@
|
||||
"@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
+25
-11
@@ -20,9 +20,15 @@ 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
|
||||
@@ -47,7 +53,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)
|
||||
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1576,6 +1582,11 @@ 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'}
|
||||
@@ -2202,13 +2213,13 @@ snapshots:
|
||||
chai: 5.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0))':
|
||||
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))':
|
||||
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)
|
||||
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -2239,7 +2250,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)
|
||||
vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -2987,13 +2998,13 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
vite-node@3.2.4(@types/node@24.2.0):
|
||||
vite-node@3.2.4(@types/node@24.2.0)(yaml@2.8.2):
|
||||
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)
|
||||
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -3008,7 +3019,7 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@7.0.6(@types/node@24.2.0):
|
||||
vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.8
|
||||
fdir: 6.4.6(picomatch@4.0.3)
|
||||
@@ -3019,12 +3030,13 @@ 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):
|
||||
vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2):
|
||||
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))
|
||||
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@@ -3042,8 +3054,8 @@ snapshots:
|
||||
tinyglobby: 0.2.14
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.0.6(@types/node@24.2.0)
|
||||
vite-node: 3.2.4(@types/node@24.2.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)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.2.0
|
||||
@@ -3079,6 +3091,8 @@ 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: {}
|
||||
|
||||
+23
-7
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -447,15 +446,26 @@ 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; only ADDED operations are permitted
|
||||
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
|
||||
// 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) {
|
||||
throw new Error(
|
||||
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`
|
||||
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`
|
||||
);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -498,9 +508,15 @@ export class ArchiveCommand {
|
||||
for (const name of plan.removed) {
|
||||
const key = normalizeRequirementName(name);
|
||||
if (!nameToBlock.has(key)) {
|
||||
throw new Error(
|
||||
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
nameToBlock.delete(key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SchemaYaml } from './types.js';
|
||||
|
||||
/**
|
||||
* Built-in schema definitions.
|
||||
* These are compiled into the package, avoiding runtime file resolution issues.
|
||||
*/
|
||||
|
||||
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
|
||||
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: 'templates/proposal.md',
|
||||
requires: [],
|
||||
},
|
||||
{
|
||||
id: 'specs',
|
||||
generates: 'specs/*.md',
|
||||
description: 'Detailed specifications for the change',
|
||||
template: 'templates/spec.md',
|
||||
requires: ['proposal'],
|
||||
},
|
||||
{
|
||||
id: 'design',
|
||||
generates: 'design.md',
|
||||
description: 'Technical design document with implementation details',
|
||||
template: 'templates/design.md',
|
||||
requires: ['proposal'],
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
generates: 'tasks.md',
|
||||
description: 'Implementation tasks derived from specs and design',
|
||||
template: 'templates/tasks.md',
|
||||
requires: ['specs', 'design'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const TDD_SCHEMA: SchemaYaml = {
|
||||
name: 'tdd',
|
||||
version: 1,
|
||||
description: 'Test-driven development workflow - tests → implementation → docs',
|
||||
artifacts: [
|
||||
{
|
||||
id: 'spec',
|
||||
generates: 'spec.md',
|
||||
description: 'Feature specification defining requirements',
|
||||
template: 'templates/spec.md',
|
||||
requires: [],
|
||||
},
|
||||
{
|
||||
id: 'tests',
|
||||
generates: 'tests/*.test.ts',
|
||||
description: 'Test files written before implementation',
|
||||
template: 'templates/test.md',
|
||||
requires: ['spec'],
|
||||
},
|
||||
{
|
||||
id: 'implementation',
|
||||
generates: 'src/*.ts',
|
||||
description: 'Implementation code to pass the tests',
|
||||
template: 'templates/implementation.md',
|
||||
requires: ['tests'],
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
generates: 'docs/*.md',
|
||||
description: 'Documentation for the implemented feature',
|
||||
template: 'templates/docs.md',
|
||||
requires: ['implementation'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Map of built-in schema names to their definitions */
|
||||
export const BUILTIN_SCHEMAS: Record<string, SchemaYaml> = {
|
||||
'spec-driven': SPEC_DRIVEN_SCHEMA,
|
||||
'tdd': TDD_SCHEMA,
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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 } from './resolver.js';
|
||||
|
||||
// Built-in schemas
|
||||
export { BUILTIN_SCHEMAS, SPEC_DRIVEN_SCHEMA, TDD_SCHEMA } from './builtin-schemas.js';
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getGlobalDataDir } from '../global-config.js';
|
||||
import { BUILTIN_SCHEMAS } from './builtin-schemas.js';
|
||||
import { parseSchema, SchemaValidationError } from './schema.js';
|
||||
import type { SchemaYaml } from './types.js';
|
||||
|
||||
/**
|
||||
* Error thrown when loading a global schema override fails.
|
||||
*/
|
||||
export class SchemaLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly schemaPath: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SchemaLoadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a schema name to a SchemaYaml object.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Global user override: ${XDG_DATA_HOME}/openspec/schemas/<name>.yaml
|
||||
* 2. Built-in schema
|
||||
*
|
||||
* @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 builtinNames = Object.keys(BUILTIN_SCHEMAS).join(', ');
|
||||
|
||||
// 1. Check global user override (returns path if found)
|
||||
const globalPath = getGlobalSchemaPath(normalizedName);
|
||||
if (globalPath) {
|
||||
// User override found - load and validate through the same pipeline as other schemas
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(globalPath, 'utf-8');
|
||||
} catch (err) {
|
||||
const ioError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new SchemaLoadError(
|
||||
`Failed to read global schema override at '${globalPath}': ${ioError.message}`,
|
||||
globalPath,
|
||||
ioError
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return parseSchema(content);
|
||||
} catch (err) {
|
||||
if (err instanceof SchemaValidationError) {
|
||||
// Re-wrap validation errors to include the file path for context
|
||||
throw new SchemaLoadError(
|
||||
`Invalid global schema override at '${globalPath}': ${err.message}`,
|
||||
globalPath,
|
||||
err
|
||||
);
|
||||
}
|
||||
// Handle unexpected parse errors (e.g., YAML syntax errors)
|
||||
const parseError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new SchemaLoadError(
|
||||
`Failed to parse global schema override at '${globalPath}': ${parseError.message}`,
|
||||
globalPath,
|
||||
parseError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check built-in schemas
|
||||
const builtin = BUILTIN_SCHEMAS[normalizedName];
|
||||
if (builtin) {
|
||||
return builtin;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Schema '${normalizedName}' not found. Checked global overrides and built-in schemas. Available built-ins: ${builtinNames}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to a global user override schema, if it exists.
|
||||
*/
|
||||
function getGlobalSchemaPath(name: string): string | null {
|
||||
const globalDir = path.join(getGlobalDataDir(), 'schemas');
|
||||
|
||||
// Check both .yaml and .yml extensions
|
||||
for (const ext of ['.yaml', '.yml']) {
|
||||
const schemaPath = path.join(globalDir, `${name}${ext}`);
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
return schemaPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available schema names.
|
||||
* Combines built-in and user override schemas.
|
||||
*/
|
||||
export function listSchemas(): string[] {
|
||||
const schemas = new Set<string>(Object.keys(BUILTIN_SCHEMAS));
|
||||
|
||||
// Add user override schemas
|
||||
const globalDir = path.join(getGlobalDataDir(), 'schemas');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
for (const file of fs.readdirSync(globalDir)) {
|
||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
||||
schemas.add(file.replace(/\.ya?ml$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(schemas).sort();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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,6 +3,7 @@ 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 {
|
||||
@@ -43,7 +44,7 @@ export class JsonConverter {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 {
|
||||
@@ -45,6 +46,37 @@ 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.
|
||||
*/
|
||||
|
||||
+3
-1
@@ -2,9 +2,11 @@
|
||||
export {
|
||||
GLOBAL_CONFIG_DIR_NAME,
|
||||
GLOBAL_CONFIG_FILE_NAME,
|
||||
GLOBAL_DATA_DIR_NAME,
|
||||
type GlobalConfig,
|
||||
getGlobalConfigDir,
|
||||
getGlobalConfigPath,
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig
|
||||
saveGlobalConfig,
|
||||
getGlobalDataDir
|
||||
} 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) => p.replace(/\\/g, '/'));
|
||||
const normalized = updatedSlashFiles.map((p) => FileSystemUtils.toPosixPath(p));
|
||||
summaryParts.push(`Updated slash commands: ${normalized.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ 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;
|
||||
@@ -359,7 +360,7 @@ export class Validator {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
// Look for the directory name after 'specs' or 'changes'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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,6 +42,14 @@ 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('\\');
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,2 +1,3 @@
|
||||
// Shared utilities will be implemented here
|
||||
export {};
|
||||
// Shared utilities
|
||||
export { validateChangeName, createChange } from './change-utils.js';
|
||||
export type { ValidationResult } from './change-utils.js';
|
||||
@@ -127,6 +127,133 @@ 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 })
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
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 } from '../../../src/core/artifact-graph/resolver.js';
|
||||
import { BUILTIN_SCHEMAS } from '../../../src/core/artifact-graph/builtin-schemas.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('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 global override over built-in', () => {
|
||||
// Set up global data dir
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { 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: templates/custom.md
|
||||
`;
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), customSchema);
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('custom-override');
|
||||
expect(schema.version).toBe(99);
|
||||
});
|
||||
|
||||
it('should validate global override and throw on invalid schema', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
|
||||
// Create an invalid schema (missing required fields)
|
||||
const invalidSchema = `
|
||||
name: invalid
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: broken
|
||||
# missing generates, description, template
|
||||
`;
|
||||
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
|
||||
fs.writeFileSync(schemaPath, invalidSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError);
|
||||
});
|
||||
|
||||
it('should include file path in validation error message', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
|
||||
const invalidSchema = `
|
||||
name: invalid
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: broken
|
||||
`;
|
||||
const schemaPath = path.join(globalSchemaDir, 'spec-driven.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 global override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
|
||||
// Create a schema with cyclic dependencies
|
||||
const cyclicSchema = `
|
||||
name: cyclic
|
||||
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]
|
||||
`;
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), cyclicSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);
|
||||
});
|
||||
|
||||
it('should detect invalid requires references in global override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { 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: templates/a.md
|
||||
requires: [nonexistent]
|
||||
`;
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.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 globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
|
||||
// Create malformed YAML
|
||||
const malformedYaml = `
|
||||
name: bad
|
||||
version: [[[invalid yaml
|
||||
`;
|
||||
const schemaPath = path.join(globalSchemaDir, 'spec-driven.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 global not found', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
// Don't create any global schemas
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('spec-driven');
|
||||
expect(schema).toEqual(BUILTIN_SCHEMAS['spec-driven']);
|
||||
});
|
||||
|
||||
it('should throw when schema not found', () => {
|
||||
expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('should list available built-in 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');
|
||||
}
|
||||
});
|
||||
|
||||
it('should mention both global and built-in schemas were checked in not found error', () => {
|
||||
try {
|
||||
resolveSchema('nonexistent');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
expect(error.message).toContain('global overrides');
|
||||
expect(error.message).toContain('built-in');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSchemas', () => {
|
||||
it('should list built-in schemas', () => {
|
||||
const schemas = listSchemas();
|
||||
|
||||
expect(schemas).toContain('spec-driven');
|
||||
expect(schemas).toContain('tdd');
|
||||
});
|
||||
|
||||
it('should include global override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'custom-workflow.yaml'), 'name: custom');
|
||||
|
||||
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 globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
|
||||
fs.mkdirSync(globalSchemaDir, { recursive: true });
|
||||
// Override spec-driven
|
||||
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), 'name: custom');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,9 @@ 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;
|
||||
@@ -113,11 +116,19 @@ 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,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
]
|
||||
},
|
||||
testTimeout: 10000,
|
||||
hookTimeout: 10000
|
||||
hookTimeout: 10000,
|
||||
teardownTimeout: 3000
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,3 +4,9 @@ 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