Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 93c846421a fix: include full requirement block in MODIFIED spec
Update the Schema Loading requirement to include all original scenarios
(modified as needed) per the MODIFIED requirement guidelines. The archiver
replaces the entire requirement with the provided content.
2025-12-26 23:30:10 +11:00
Tabish Bidiwale 702c163048 proposal: add instruction loader and schema restructure (Slice 3)
Adds two change proposals for implementing Slice 3 of the artifact POC:

1. restructure-schema-directories
   - Move schemas from embedded TS objects to self-contained directories
   - Each schema becomes a directory with schema.yaml + templates/
   - Enables co-located templates for user extensibility
   - 2-level resolution: user override → package built-in

2. add-instruction-loader (depends on #1)
   - Load templates from schema directories
   - Enrich templates with change context (dependencies, next steps)
   - Format change status for CLI output
   - New instruction-loader capability

These proposals complete Slice 3 from docs/artifact_poc.md.
2025-12-26 23:13:13 +11:00
Tabish Bidiwale 4f4af5708d feat: add change creation utilities (#408)
* proposal: add change manager - extract + new functionality

Slice 2 of the artifact tracker POC. Creates ChangeManager module that:

**Extracts existing functionality:**
- `listChanges()` from ListCommand + ChangeCommand.getActiveChanges()
- `changeExists()` from inline fs.access() checks
- `getChangePath()` from inline path.join() calls
- `isInitialized()` from ListCommand directory check

**Adds new functionality:**
- `createChange(name, description?)` - create change directory + README
- `validateName(name)` - enforce kebab-case naming

**Refactors CLI commands to be thin wrappers:**
- ListCommand delegates to ChangeManager
- ChangeCommand delegates to ChangeManager

Also updates docs/artifact_poc.md to reflect XDG decisions from Slice 1.

* proposal: simplify to utility functions only

Remove extraction/refactor scope. Just add:
- createChange(projectRoot, name, description?)
- validateChangeName(name)

Simple utility functions in src/utils/change-utils.ts.
No class, no abstraction layer.

* docs: update artifact_poc.md for simplified Slice 2

- Rename ChangeManager to change-utils (simple utility functions)
- Remove extracted methods (listChanges, getChangePath, etc.)
- Keep only new functionality: createChange(), validateChangeName()
- Update component diagram and summary table

* docs: clarify existing vs new functionality in artifact_poc.md

Audit artifact_poc.md against existing codebase to mark what already
exists vs what's genuinely new:

- Slice 4 CLI table: Added Status column (NEW/EXISTS)
- Added "Existing CLI commands" section listing what's not in scope
- Updated Implementation Order Slice 4 with explicit new vs existing
- Summary table: Updated status + added "What already exists" section

Key finding: The document was already well-simplified. Only truly new
functionality (createChange, validateChangeName, InstructionLoader,
artifact graph CLI commands) is proposed.

* rename: change-manager -> change-creation capability

The capability name "change-manager" implied a manager class abstraction,
but the simplified proposal uses only utility functions. Renamed to
"change-creation" to accurately reflect what the capability provides.

* feat: implement change creation utilities

Add createChange() and validateChangeName() functions for programmatic
change directory creation with kebab-case validation.

- createChange(projectRoot, name) creates openspec/changes/<name>/
- validateChangeName() enforces kebab-case naming conventions
- Comprehensive test coverage (21 tests)

* chore: archive add-change-manager change

Move change to archive and create change-creation spec with
requirements for createChange() and validateChangeName().

* docs: update change-creation spec purpose
2025-12-26 22:14:56 +11:00
Tabish Bidiwale 9822576770 fix(artifact-graph): normalize paths for cross-platform glob compatibility (#407)
Add FileSystemUtils.toPosixPath() utility and consolidate scattered
path normalization patterns. This fixes Windows test failures where
fast-glob couldn't match paths containing backslashes.

Root cause: path.join() uses backslashes on Windows, but fast-glob
requires forward slashes for glob patterns on all platforms.

Changes:
- Add toPosixPath() to FileSystemUtils for cross-platform path handling
- Update artifact-graph/state.ts to normalize glob patterns
- Consolidate path normalization in update.ts, validator.ts, and
  json-converter.ts to use the new utility
2025-12-25 22:42:00 +11:00
Tabish Bidiwale af273b8e0b proposal: add artifact graph core query system (#400)
* proposal: add artifact graph core query system

Add OpenSpec change proposal for Slice 1 of the artifact POC - the core
"What's Ready?" query system. This implements:

- ArtifactGraph class for DAG-based dependency modeling
- Filesystem-based state detection (file existence = completion)
- Topological sort for build order calculation
- Ready/blocked artifact queries

This is a parallel module that will coexist with the current system.

* docs: specify Zod for schema validation in artifact graph proposal

- Add decision section for Zod schema validation in design.md
- Update data structures to show Zod schemas with z.infer<> types
- Update tasks to specify Zod usage for type definitions and parsing

* docs: add 2-level schema resolution and built-in schemas

- Add decision for global → built-in schema resolution pattern
- Add resolver.ts for schema lookup logic
- Add built-in schemas directory (spec-driven.yaml, tdd.yaml)
- Add schema resolution tests
- Follows ESLint/Prettier/Git patterns (defaults baked in package)

* experiment: add vertical slice version of artifact graph change

Creates add-artifact-graph-core-v2 with requirements organized as
vertical slices - each requirement file contains its spec, design
decisions, and tasks bundled together for comparison.

* feat(core): add getGlobalDataDir for XDG-compliant data directory

Add getGlobalDataDir() function following XDG Base Directory Specification
for storing user data like schema overrides:
- XDG_DATA_HOME takes precedence on all platforms
- Unix/macOS fallback: ~/.local/share/openspec/
- Windows fallback: %LOCALAPPDATA%/openspec/

* feat(artifact-graph): add core dependency graph module

Implement Slice 1 ("What's Ready?") of the artifact graph system:

- types.ts: Zod schemas for artifact definitions with derived TypeScript types
- schema.ts: YAML parsing with validation for duplicates, invalid refs, cycles
- graph.ts: ArtifactGraph class with Kahn's algorithm for topological sort
- state.ts: Filesystem-based completion detection with glob pattern support
- resolver.ts: Two-level schema resolution (global override → built-in)
- builtin-schemas.ts: spec-driven and tdd workflow definitions

Key design decisions:
- Filesystem as database (stateless, git-friendly)
- Cycle errors show full path (e.g., "A → B → C → A")
- Deterministic ordering via sorted queues

* test(artifact-graph): add comprehensive test suite

52 tests covering all artifact-graph functionality:

- schema.test.ts: Parsing, validation errors, cycle detection
- graph.test.ts: Build order, ready artifacts, blocked queries
- state.test.ts: File existence, glob patterns, missing directories
- resolver.test.ts: Schema resolution with global overrides

* docs(openspec): archive add-artifact-graph-core change

Archive completed change proposal and create artifact-graph spec with
6 requirements covering schema loading, build order, state detection,
ready queries, completion checks, and blocked queries.

* chore: remove experimental artifact-graph-core-v2 folder

Clean up experimental vertical slice proposal that is no longer needed.

* feat(artifact-graph): validate global schema overrides

Global schema overrides are now validated through the same pipeline as
built-in schemas, catching invalid schemas, cyclic dependencies, and
invalid requires references at load time. Added SchemaLoadError for
better error context with file paths.

* test(artifact-graph): add workflow integration tests

Add end-to-end integration tests that exercise the full artifact-graph
pipeline: resolveSchema → ArtifactGraph → detectCompleted → queries.

Tests cover:
- Complete spec-driven and tdd workflow progressions
- Out-of-order file creation handling
- Glob pattern matching with multiple files
- Build order consistency
- Edge cases (empty/missing directories, non-matching files)

* refactor(artifact-graph): adopt zod v4 error message format

Update custom error messages from string format to zod v4 object format
using `{ error: 'message' }` convention.

* fix(test): prevent hanging vitest threads after test runs

- Add teardownTimeout (3s) to vitest config for forced cleanup
- Add global teardown function to vitest.setup.ts
- Call child.unref() to prevent child processes from blocking event loop
- Explicitly destroy stdio streams on process close/error
2025-12-25 22:09:42 +11:00
Eunsong-Park 3ceef2db72 fix(archive): allow REMOVED requirements when creating new spec files (#403) (#404)
When creating a new spec file, REMOVED requirements are now ignored
with a warning instead of causing archive to fail. This enables
refactoring scenarios where old fields are removed while documenting
a capability for the first time.

Fixes #403
2025-12-25 03:44:34 +11:00
Tabish Bidiwale 2c2599b1f0 docs: add artifact POC analysis document (#398)
Add internal documentation for the artifact-based approach to OpenSpec
core. This document outlines design decisions, terminology, and the
philosophy behind treating dependencies as enablers rather than gates.
2025-12-23 22:21:11 +11:00
github-actions[bot]andTabish Bidiwale c08a53cb21 chore(release): version packages (#397)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-12-23 12:59:10 +11:00
Tabish Bidiwale 455c65f3c4 Add changeset for --no-interactive flag fix (#396) 2025-12-23 12:45:56 +11:00
49 changed files with 4149 additions and 30 deletions
-2
View File
@@ -140,8 +140,6 @@ dist/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Internal Docs
docs/
# Claude
.claude/
+6
View File
@@ -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
+593
View File
@@ -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
@@ -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
+107
View File
@@ -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'] }`
+67
View File
@@ -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
View File
@@ -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"
}
}
+25 -11
View File
@@ -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
View File
@@ -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,
};
+167
View File
@@ -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;
}
}
+24
View File
@@ -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';
+121
View File
@@ -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();
}
+124
View File
@@ -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}`);
}
}
}
}
+64
View File
@@ -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;
}
+33
View File
@@ -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[];
}
+2 -1
View File
@@ -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--) {
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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(', ')}`);
}
+4 -3
View File
@@ -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'
+102
View File
@@ -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);
}
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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 })
+268
View File
@@ -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({});
});
});
});
+271
View File
@@ -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);
});
});
});
+207
View File
@@ -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([]);
});
});
});
+174
View File
@@ -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);
});
});
});
+11
View File
@@ -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,
+176
View File
@@ -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
View File
@@ -20,6 +20,7 @@ export default defineConfig({
]
},
testTimeout: 10000,
hookTimeout: 10000
hookTimeout: 10000,
teardownTimeout: 3000
}
});
+6
View File
@@ -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
}