mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-12 05:00:36 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acdce5b2ba | ||
|
|
4f47bd2f37 | ||
|
|
9eb22410a9 | ||
|
|
f8747203a7 | ||
|
|
17016d5eaa | ||
|
|
a91adbdbd7 | ||
|
|
ac8f6d281c | ||
|
|
44ca42fbe1 | ||
|
|
39645d9284 | ||
|
|
11e195575f | ||
|
|
ab47cc6b00 | ||
|
|
8dcd1707ee | ||
|
|
4f4af5708d | ||
|
|
9822576770 | ||
|
|
af273b8e0b | ||
|
|
3ceef2db72 | ||
|
|
2c2599b1f0 | ||
|
|
c08a53cb21 | ||
|
|
455c65f3c4 | ||
|
|
9ac6330430 | ||
|
|
fb264bcbcd | ||
|
|
a2757e7856 | ||
|
|
6d84924c18 | ||
|
|
6de04f3b2b | ||
|
|
c2a1a4c807 | ||
|
|
2e71835d23 | ||
|
|
971f8ca4a3 | ||
|
|
68e0a7e68e |
@@ -142,6 +142,9 @@ jobs:
|
||||
- name: Type check
|
||||
run: pnpm exec tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Check for build artifacts
|
||||
run: |
|
||||
if [ ! -d "dist" ]; then
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write # Required for npm OIDC trusted publishing
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -27,11 +28,9 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@fission-ai'
|
||||
always-auth: true
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -46,5 +45,4 @@ jobs:
|
||||
publish: pnpm run release:ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
# npm authentication handled via OIDC trusted publishing (no token needed)
|
||||
|
||||
@@ -140,8 +140,6 @@ dist/
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Internal Docs
|
||||
docs/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# @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
|
||||
|
||||
- a2757e7: Fix pre-commit hook hang issue in config command by using dynamic import for @inquirer/prompts
|
||||
|
||||
The config command was causing pre-commit hooks to hang indefinitely due to stdin event listeners being registered at module load time. This fix converts the static import to a dynamic import that only loads inquirer when the `config reset` command is actually used interactively.
|
||||
|
||||
Also adds ESLint with a rule to prevent static @inquirer imports, avoiding future regressions.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 2e71835: ### New Features
|
||||
|
||||
- Add `openspec config` command for managing global configuration settings
|
||||
- Implement global config directory with XDG Base Directory specification support
|
||||
- Add Oh-my-zsh shell completions support for enhanced CLI experience
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix hang in pre-commit hooks by using dynamic imports
|
||||
- Respect XDG_CONFIG_HOME environment variable on all platforms
|
||||
- Resolve Windows compatibility issues in zsh-installer tests
|
||||
- Align cli-completion spec with implementation
|
||||
- Remove hardcoded agent field from slash commands
|
||||
|
||||
### Documentation
|
||||
|
||||
- Alphabetize AI tools list in README and make it collapsible
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
# POC-OpenSpec-Core Analysis
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions & Terminology
|
||||
|
||||
### Philosophy: Not a Workflow System
|
||||
|
||||
This system is **not** a workflow engine. It's an **artifact tracker with dependency awareness**.
|
||||
|
||||
| What it's NOT | What it IS |
|
||||
|---------------|------------|
|
||||
| Linear step-by-step progression | Exploratory, iterative planning |
|
||||
| Bureaucratic checkpoints | Enablers that unlock possibilities |
|
||||
| "You must complete step 1 first" | "Here's what you could create now" |
|
||||
| Form-filling | Fluid document creation |
|
||||
|
||||
**Key insight:** Dependencies are *enablers*, not *gates*. You can't meaningfully write a design document if there's no proposal to design from - that's not bureaucracy, it's logic.
|
||||
|
||||
### Terminology
|
||||
|
||||
| Term | Definition | Example |
|
||||
|------|------------|---------|
|
||||
| **Change** | A unit of work being planned (feature, refactor, migration) | `openspec/changes/add-auth/` |
|
||||
| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `spec-driven.yaml` |
|
||||
| **Artifact** | A node in the graph (a document to create) | `proposal`, `design`, `specs` |
|
||||
| **Template** | Instructions/guidance for creating an artifact | `templates/proposal.md` |
|
||||
|
||||
### Hierarchy
|
||||
|
||||
```
|
||||
Schema (defines) ──→ Artifacts (guided by) ──→ Templates
|
||||
```
|
||||
|
||||
- **Schema** = the artifact graph (what exists, dependencies)
|
||||
- **Artifact** = a document to produce
|
||||
- **Template** = instructions for creating that artifact
|
||||
|
||||
### Schema Variations
|
||||
|
||||
Schemas can vary across multiple dimensions:
|
||||
|
||||
| Dimension | Examples |
|
||||
|-----------|----------|
|
||||
| Philosophy | `spec-driven`, `tdd`, `prototype-first` |
|
||||
| Version | `v1`, `v2`, `v3` |
|
||||
| Language | `en`, `zh`, `es` |
|
||||
| Custom | `team-alpha`, `experimental` |
|
||||
|
||||
### Schema Resolution (XDG Standard)
|
||||
|
||||
Schemas follow the XDG Base Directory Specification with a 2-level resolution:
|
||||
|
||||
```
|
||||
1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # Global user override
|
||||
2. <package>/schemas/<name>/schema.yaml # Built-in defaults
|
||||
```
|
||||
|
||||
**Platform-specific paths:**
|
||||
- Unix/macOS: `~/.local/share/openspec/schemas/`
|
||||
- Windows: `%LOCALAPPDATA%/openspec/schemas/`
|
||||
- All platforms: `$XDG_DATA_HOME/openspec/schemas/` (when set)
|
||||
|
||||
**Why XDG?**
|
||||
- Schemas are workflow definitions (data), not user preferences (config)
|
||||
- Built-ins baked into package, never auto-copied
|
||||
- Users customize by creating files in global data dir
|
||||
- Consistent with modern CLI tooling standards
|
||||
|
||||
### Template Inheritance (2 Levels Max)
|
||||
|
||||
Templates are co-located with schemas in a `templates/` subdirectory:
|
||||
|
||||
```
|
||||
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
|
||||
2. <package>/schemas/<schema>/templates/<artifact>.md # Built-in
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- User overrides take precedence over package built-ins
|
||||
- A CLI command shows resolved paths (no guessing)
|
||||
- No inheritance between schemas (copy if you need to diverge)
|
||||
- Templates are always co-located with their schema
|
||||
|
||||
**Why this matters:**
|
||||
- Avoids "where does this come from?" debugging
|
||||
- No implicit magic that works until it doesn't
|
||||
- Schema + templates form a cohesive unit
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This is an **artifact tracker with dependency awareness** that guides iterative development through a structured artifact pipeline. The core innovation is using the **filesystem as a database** - artifact completion is detected by file existence, making the system stateless and version-control friendly.
|
||||
|
||||
The system answers:
|
||||
- "What artifacts exist for this change?"
|
||||
- "What could I create next?" (not "what must I create")
|
||||
- "What's blocking X?" (informational, not prescriptive)
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. ArtifactGraph (Slice 1 - COMPLETE)
|
||||
|
||||
The dependency graph engine with XDG-compliant schema resolution.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Model artifacts as a DAG | Artifact with `requires: string[]` |
|
||||
| Track completion state | `Set<string>` for completed artifacts |
|
||||
| Calculate build order | Kahn's algorithm (topological sort) |
|
||||
| Find ready artifacts | Check if all dependencies are in `completed` set |
|
||||
| Resolve schemas | XDG global → package built-ins |
|
||||
|
||||
**Key Data Structures (Zod-validated):**
|
||||
|
||||
```typescript
|
||||
// Zod schemas define types + validation
|
||||
const ArtifactSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
|
||||
description: z.string(),
|
||||
template: z.string(), // path to template file
|
||||
requires: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.number().int().positive(),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1),
|
||||
});
|
||||
|
||||
// Derived types
|
||||
type Artifact = z.infer<typeof ArtifactSchema>;
|
||||
type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
- `resolveSchema(name)` - Load schema with XDG fallback
|
||||
- `ArtifactGraph.fromSchema(schema)` - Build graph from schema
|
||||
- `detectState(graph, changeDir)` - Scan filesystem for completion
|
||||
- `getNextArtifacts(graph, completed)` - Find artifacts ready to create
|
||||
- `getBuildOrder(graph)` - Topological sort of all artifacts
|
||||
- `getBlocked(graph, completed)` - Artifacts with unmet dependencies
|
||||
|
||||
---
|
||||
|
||||
### 2. Change Utilities (Slice 2)
|
||||
|
||||
Simple utility functions for programmatic change creation. No class, no abstraction layer.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Create changes | Create dirs under `openspec/changes/<name>/` with README |
|
||||
| Name validation | Enforce kebab-case naming |
|
||||
|
||||
**Key Paths:**
|
||||
|
||||
```
|
||||
openspec/changes/<name>/ → Change instances with artifacts (project-level)
|
||||
```
|
||||
|
||||
**Key Functions** (`src/utils/change-utils.ts`):
|
||||
- `createChange(projectRoot, name, description?)` - Create new change directory + README
|
||||
- `validateChangeName(name)` - Validate kebab-case naming, returns `{ valid, error? }`
|
||||
|
||||
**Note:** Existing CLI commands (`ListCommand`, `ChangeCommand`) already handle listing, path resolution, and existence checks. No need to extract that logic - it works fine as-is.
|
||||
|
||||
---
|
||||
|
||||
### 3. InstructionLoader (Slice 3)
|
||||
|
||||
Template resolution and instruction enrichment.
|
||||
|
||||
| Responsibility | Approach |
|
||||
|----------------|----------|
|
||||
| Resolve templates | XDG 2-level fallback (schema-specific → shared → built-in) |
|
||||
| Build dynamic context | Gather dependency status, change info |
|
||||
| Enrich templates | Inject context into base templates |
|
||||
| Generate status reports | Formatted markdown with progress |
|
||||
|
||||
**Key Class - ChangeState:**
|
||||
|
||||
```
|
||||
ChangeState {
|
||||
changeName: string
|
||||
changeDir: string
|
||||
graph: ArtifactGraph
|
||||
completed: Set<string>
|
||||
|
||||
// Methods
|
||||
getNextSteps(): string[]
|
||||
getStatus(artifactId): ArtifactStatus
|
||||
isComplete(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Key Functions:**
|
||||
- `getTemplatePath(artifactId, schemaName?)` - Resolve with 2-level fallback
|
||||
- `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point
|
||||
- `getChangeStatus(projectRoot, changeName?)` - Formatted status report
|
||||
|
||||
---
|
||||
|
||||
### 4. CLI (Slice 4)
|
||||
|
||||
User interface layer. **All commands are deterministic** - require explicit `--change` parameter.
|
||||
|
||||
| Command | Function | Status |
|
||||
|---------|----------|--------|
|
||||
| `status --change <id>` | Show change progress (artifact graph) | **NEW** |
|
||||
| `next --change <id>` | Show artifacts ready to create | **NEW** |
|
||||
| `instructions <artifact> --change <id>` | Get enriched instructions for artifact | **NEW** |
|
||||
| `list` | List all changes | EXISTS (`openspec change list`) |
|
||||
| `new <name>` | Create change | **NEW** (uses `createChange()`) |
|
||||
| `init` | Initialize structure | EXISTS (`openspec init`) |
|
||||
| `templates --change <id>` | Show resolved template paths | **NEW** |
|
||||
|
||||
**Note:** Commands that operate on a change require `--change`. Missing parameter → error with list of available changes. Agent infers the change from conversation and passes it explicitly.
|
||||
|
||||
**Existing CLI commands** (not part of this slice):
|
||||
- `openspec change list` / `openspec change show <id>` / `openspec change validate <id>`
|
||||
- `openspec list --changes` / `openspec list --specs`
|
||||
- `openspec view` (dashboard)
|
||||
- `openspec init` / `openspec archive <change>`
|
||||
|
||||
---
|
||||
|
||||
### 5. Claude Commands
|
||||
|
||||
Integration layer for Claude Code. **Operational commands only** - artifact creation via natural language.
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/status` | Show change progress |
|
||||
| `/next` | Show what's ready to create |
|
||||
| `/run [artifact]` | Execute a specific step (power users) |
|
||||
| `/list` | List all changes |
|
||||
| `/new <name>` | Create a new change |
|
||||
| `/init` | Initialize structure |
|
||||
|
||||
**Artifact creation:** Users say "create the proposal" or "write the tests" in natural language. The agent:
|
||||
1. Infers change from conversation (confirms if uncertain)
|
||||
2. Infers artifact from request
|
||||
3. Calls CLI with explicit `--change` parameter
|
||||
4. Creates artifact following instructions
|
||||
|
||||
This works for ANY artifact in ANY schema - no new slash commands needed when schemas change.
|
||||
|
||||
**Note:** Legacy commands (`/openspec-proposal`, `/openspec-apply`, `/openspec-archive`) exist in the main project for backward compatibility but are separate from this architecture.
|
||||
|
||||
---
|
||||
|
||||
## Component Dependency Graph
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION LAYER │
|
||||
│ ┌──────────────┐ ┌────────────────────┐ │
|
||||
│ │ CLI │ ←─shell exec───────│ Claude Commands │ │
|
||||
│ └──────┬───────┘ └────────────────────┘ │
|
||||
└─────────┼───────────────────────────────────────────────────┘
|
||||
│ imports
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ORCHESTRATION LAYER │
|
||||
│ ┌────────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ InstructionLoader │ │ change-utils (Slice 2) │ │
|
||||
│ │ (Slice 3) │ │ createChange() │ │
|
||||
│ └─────────┬──────────┘ │ validateChangeName() │ │
|
||||
│ │ └──────────────────────────┘ │
|
||||
└────────────┼────────────────────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CORE LAYER │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ ArtifactGraph (Slice 1) │ │
|
||||
│ │ │ │
|
||||
│ │ Schema Resolution (XDG) ──→ Graph ──→ State Detection│ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ reads from
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PERSISTENCE LAYER │
|
||||
│ ┌──────────────────┐ ┌────────────────────────────────┐ │
|
||||
│ │ XDG Schemas │ │ Project Artifacts │ │
|
||||
│ │ ~/.local/share/ │ │ openspec/changes/<name>/ │ │
|
||||
│ │ openspec/ │ │ - proposal.md, design.md │ │
|
||||
│ │ schemas/ │ │ - specs/*.md, tasks.md │ │
|
||||
│ └──────────────────┘ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Design Patterns
|
||||
|
||||
### 1. Filesystem as Database
|
||||
|
||||
No SQLite, no JSON state files. The existence of `proposal.md` means proposal is complete.
|
||||
|
||||
```
|
||||
// State detection is just file existence checking
|
||||
if (exists(artifactPath)) {
|
||||
completed.add(artifactId)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Deterministic CLI, Inferring Agent
|
||||
|
||||
**CLI layer:** Always deterministic - requires explicit `--change` parameter.
|
||||
|
||||
```
|
||||
openspec status --change add-auth # explicit, works
|
||||
openspec status # error: "No change specified"
|
||||
```
|
||||
|
||||
**Agent layer:** Infers from conversation, confirms if uncertain, passes explicit `--change`.
|
||||
|
||||
This separation means:
|
||||
- CLI is pure, testable, no state to corrupt
|
||||
- Agent handles all "smartness"
|
||||
- No config.yaml tracking of "active change"
|
||||
|
||||
### 3. XDG-Compliant Schema Resolution
|
||||
|
||||
```
|
||||
${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
|
||||
↓ (not found)
|
||||
<package>/schemas/<name>/schema.yaml # Built-in
|
||||
↓ (not found)
|
||||
Error (schema not found)
|
||||
```
|
||||
|
||||
### 4. Two-Level Template Fallback
|
||||
|
||||
```
|
||||
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
|
||||
↓ (not found)
|
||||
<package>/schemas/<schema>/templates/<artifact>.md # Built-in
|
||||
↓ (not found)
|
||||
Error (no silent fallback to avoid confusion)
|
||||
```
|
||||
|
||||
### 5. Glob Pattern Support
|
||||
|
||||
`specs/*.md` allows multiple files to satisfy a single artifact:
|
||||
|
||||
```
|
||||
if (artifact.generates.includes("*")) {
|
||||
const parentDir = changeDir / patternParts[0]
|
||||
if (exists(parentDir) && hasFiles(parentDir)) {
|
||||
completed.add(artifactId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Stateless State Detection
|
||||
|
||||
Every command re-scans the filesystem. No cached state to corrupt.
|
||||
|
||||
---
|
||||
|
||||
## Artifact Pipeline (Default Schema)
|
||||
|
||||
The default `spec-driven` schema:
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ proposal │ (no dependencies)
|
||||
└────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ specs │ (requires: proposal)
|
||||
└────┬─────┘
|
||||
│
|
||||
├──────────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ design │ │ │
|
||||
│ │◄──┤ proposal │
|
||||
└────┬─────┘ └──────────┘
|
||||
│ (requires: proposal, specs)
|
||||
▼
|
||||
┌──────────┐
|
||||
│ tasks │ (requires: design)
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
Other schemas (TDD, prototype-first) would have different graphs.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Structured as **vertical slices** - each slice is independently testable.
|
||||
|
||||
---
|
||||
|
||||
### Slice 1: "What's Ready?" (Core Query) ✅ COMPLETE
|
||||
|
||||
**Delivers:** Types + Graph + State Detection + Schema Resolution
|
||||
|
||||
**Implementation:** `src/core/artifact-graph/`
|
||||
- `types.ts` - Zod schemas and derived TypeScript types
|
||||
- `schema.ts` - YAML parsing with Zod validation
|
||||
- `graph.ts` - ArtifactGraph class with topological sort
|
||||
- `state.ts` - Filesystem-based state detection
|
||||
- `resolver.ts` - XDG-compliant schema resolution
|
||||
- `builtin-schemas.ts` - Package-bundled default schemas
|
||||
|
||||
**Key decisions made:**
|
||||
- Zod for schema validation (consistent with project)
|
||||
- XDG for global schema overrides
|
||||
- `Set<string>` for completion state (immutable, functional)
|
||||
- `inProgress` and `failed` states deferred (require external tracking)
|
||||
|
||||
---
|
||||
|
||||
### Slice 2: "Change Creation Utilities"
|
||||
|
||||
**Delivers:** Utility functions for programmatic change creation
|
||||
|
||||
**Scope:**
|
||||
- `createChange(projectRoot, name, description?)` → creates directory + README
|
||||
- `validateChangeName(name)` → kebab-case pattern enforcement
|
||||
|
||||
**Not in scope (already exists in CLI commands):**
|
||||
- `listChanges()` → exists in `ListCommand` and `ChangeCommand.getActiveChanges()`
|
||||
- `getChangePath()` → simple `path.join()` inline
|
||||
- `changeExists()` → simple `fs.access()` inline
|
||||
- `isInitialized()` → simple directory check inline
|
||||
|
||||
**Why simplified:** Extracting existing CLI logic into a class would require similar refactoring of `SpecCommand` for consistency. The existing code works fine (~15 lines each). Only truly new functionality is `createChange()` + name validation.
|
||||
|
||||
---
|
||||
|
||||
### Slice 3: "Get Instructions" (Enrichment)
|
||||
|
||||
**Delivers:** Template resolution + context injection
|
||||
|
||||
**Testable behaviors:**
|
||||
- Template fallback: schema-specific → shared → built-in → error
|
||||
- Context injection: completed deps show ✓, missing show ✗
|
||||
- Output path shown correctly based on change directory
|
||||
|
||||
---
|
||||
|
||||
### Slice 4: "CLI + Integration"
|
||||
|
||||
**Delivers:** New artifact graph commands (builds on existing CLI)
|
||||
|
||||
**New commands:**
|
||||
- `status --change <id>` - Show artifact completion state
|
||||
- `next --change <id>` - Show ready-to-create artifacts
|
||||
- `instructions <artifact> --change <id>` - Get enriched template
|
||||
- `templates --change <id>` - Show resolved paths
|
||||
- `new <name>` - Create change (wrapper for `createChange()`)
|
||||
|
||||
**Already exists (not in scope):**
|
||||
- `openspec change list/show/validate` - change management
|
||||
- `openspec list --changes/--specs` - listing
|
||||
- `openspec view` - dashboard
|
||||
- `openspec init` - initialization
|
||||
|
||||
**Testable behaviors:**
|
||||
- Each new command produces expected output
|
||||
- Commands compose correctly (status → next → instructions flow)
|
||||
- Error handling for missing changes, invalid artifacts, etc.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
# Global (XDG paths - user overrides)
|
||||
~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/)
|
||||
%LOCALAPPDATA%/openspec/ # Windows
|
||||
└── schemas/ # Schema overrides
|
||||
└── custom-workflow/ # User-defined schema directory
|
||||
├── schema.yaml # Schema definition
|
||||
└── templates/ # Co-located templates
|
||||
└── proposal.md
|
||||
|
||||
# Package (built-in defaults)
|
||||
<package>/
|
||||
└── schemas/ # Built-in schema definitions
|
||||
├── spec-driven/ # Default: proposal → specs → design → tasks
|
||||
│ ├── schema.yaml
|
||||
│ └── templates/
|
||||
│ ├── proposal.md
|
||||
│ ├── design.md
|
||||
│ ├── spec.md
|
||||
│ └── tasks.md
|
||||
└── tdd/ # TDD: tests → implementation → docs
|
||||
├── schema.yaml
|
||||
└── templates/
|
||||
├── test.md
|
||||
├── implementation.md
|
||||
├── spec.md
|
||||
└── docs.md
|
||||
|
||||
# Project (change instances)
|
||||
openspec/
|
||||
└── changes/ # Change instances
|
||||
├── add-auth/
|
||||
│ ├── README.md # Auto-generated on creation
|
||||
│ ├── proposal.md # Created artifacts
|
||||
│ ├── design.md
|
||||
│ └── specs/
|
||||
│ └── *.md
|
||||
├── refactor-db/
|
||||
│ └── ...
|
||||
└── archive/ # Completed changes
|
||||
└── 2025-01-01-add-auth/
|
||||
|
||||
.claude/
|
||||
├── settings.local.json # Permissions
|
||||
└── commands/ # Slash commands
|
||||
└── *.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema YAML Format
|
||||
|
||||
```yaml
|
||||
# Built-in: <package>/schemas/spec-driven/schema.yaml
|
||||
# Or user override: ~/.local/share/openspec/schemas/spec-driven/schema.yaml
|
||||
name: spec-driven
|
||||
version: 1
|
||||
description: Specification-driven development
|
||||
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: "proposal.md"
|
||||
description: "Create project proposal document"
|
||||
template: "proposal.md" # resolves from co-located templates/ directory
|
||||
requires: []
|
||||
|
||||
- id: specs
|
||||
generates: "specs/*.md" # glob pattern
|
||||
description: "Create technical specification documents"
|
||||
template: "specs.md"
|
||||
requires:
|
||||
- proposal
|
||||
|
||||
- id: design
|
||||
generates: "design.md"
|
||||
description: "Create design document"
|
||||
template: "design.md"
|
||||
requires:
|
||||
- proposal
|
||||
- specs
|
||||
|
||||
- id: tasks
|
||||
generates: "tasks.md"
|
||||
description: "Create tasks breakdown document"
|
||||
template: "tasks.md"
|
||||
requires:
|
||||
- design
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Layer | Component | Responsibility | Status |
|
||||
|-------|-----------|----------------|--------|
|
||||
| Core | ArtifactGraph | Pure dependency logic + XDG schema resolution | ✅ Slice 1 COMPLETE |
|
||||
| Utils | change-utils | Change creation + name validation only | Slice 2 (new functionality only) |
|
||||
| Core | InstructionLoader | Template resolution + enrichment | Slice 3 (all new) |
|
||||
| Presentation | CLI | New artifact graph commands | Slice 4 (new commands only) |
|
||||
| Integration | Claude Commands | AI assistant glue | Slice 4 |
|
||||
|
||||
**What already exists (not in this proposal):**
|
||||
- `getActiveChangeIds()` in `src/utils/item-discovery.ts` - list changes
|
||||
- `ChangeCommand.list/show/validate()` in `src/commands/change.ts`
|
||||
- `ListCommand.execute()` in `src/core/list.ts`
|
||||
- `ViewCommand.execute()` in `src/core/view.ts` - dashboard
|
||||
- `src/core/init.ts` - initialization
|
||||
- `src/core/archive.ts` - archiving
|
||||
|
||||
**Key Principles:**
|
||||
- **Filesystem IS the database** - stateless, version-control friendly
|
||||
- **Dependencies are enablers** - show what's possible, don't force order
|
||||
- **Deterministic CLI, inferring agent** - CLI requires explicit `--change`, agent infers from context
|
||||
- **XDG-compliant paths** - schemas and templates use standard user data directories
|
||||
- **2-level inheritance** - user override → package built-in (no deeper)
|
||||
- **Schemas are versioned** - support variations by philosophy, version, language
|
||||
@@ -0,0 +1,42 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
extends: [...tseslint.configs.recommended],
|
||||
rules: {
|
||||
// Prevent static imports of @inquirer modules to avoid pre-commit hook hangs.
|
||||
// These modules have side effects that can keep the Node.js event loop alive
|
||||
// when stdin is piped. Use dynamic import() instead.
|
||||
// See: https://github.com/Fission-AI/OpenSpec/issues/367
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@inquirer/*'],
|
||||
message:
|
||||
'Use dynamic import() for @inquirer modules to prevent pre-commit hook hangs. See #367.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// Disable rules that need broader cleanup - focus on critical issues only
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-empty': 'off',
|
||||
'prefer-const': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// init.ts is dynamically imported from cli/index.ts, so static @inquirer
|
||||
// imports there are safe - they won't be loaded at CLI startup
|
||||
files: ['src/core/init.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', '*.js', '*.mjs'],
|
||||
}
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
## Why
|
||||
|
||||
Users need a way to view and modify their global OpenSpec settings without manually editing JSON files. The `add-global-config-dir` change provides the foundation, but there's no user-facing interface to interact with the config. A dedicated `openspec config` command provides discoverability and ease of use.
|
||||
|
||||
## What Changes
|
||||
|
||||
Add `openspec config` subcommand with the following operations:
|
||||
|
||||
```bash
|
||||
openspec config path # Show config file location
|
||||
openspec config list # Show all current settings
|
||||
openspec config get <key> # Get a specific value
|
||||
openspec config set <key> <value> # Set a value
|
||||
openspec config reset [key] # Reset to defaults (all or specific key)
|
||||
```
|
||||
|
||||
**Example usage:**
|
||||
```bash
|
||||
$ openspec config path
|
||||
/Users/me/.config/openspec/config.json
|
||||
|
||||
$ openspec config list
|
||||
enableTelemetry: true
|
||||
featureFlags: {}
|
||||
|
||||
$ openspec config set enableTelemetry false
|
||||
Set enableTelemetry = false
|
||||
|
||||
$ openspec config get enableTelemetry
|
||||
false
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `cli-config` capability
|
||||
- Affected code:
|
||||
- New `src/commands/config.ts`
|
||||
- Update CLI entry point to register config command
|
||||
- Dependencies: Requires `add-global-config-dir` to be implemented first
|
||||
@@ -0,0 +1,89 @@
|
||||
## Context
|
||||
|
||||
The `global-config` spec defines how OpenSpec reads/writes `config.json`, but users currently must edit it by hand. This command provides a CLI interface to that config.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provide a discoverable CLI for config management
|
||||
- Support scripting with machine-readable output
|
||||
- Validate config changes with zod schema
|
||||
- Handle nested keys gracefully
|
||||
|
||||
**Non-Goals:**
|
||||
- Project-local config (reserved for future via `--scope` flag)
|
||||
- Complex queries (JSONPath, filtering)
|
||||
- Config file format migration
|
||||
|
||||
## Decisions
|
||||
|
||||
### Key Naming: camelCase with Dot Notation
|
||||
|
||||
**Decision:** Keys use camelCase matching the JSON structure, with dot notation for nesting.
|
||||
|
||||
**Rationale:**
|
||||
- Matches the actual JSON keys (no translation layer)
|
||||
- Dot notation is intuitive and widely used (lodash, jq, kubectl)
|
||||
- Avoids complexity of supporting multiple casing styles
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
openspec config get featureFlags # Returns object
|
||||
openspec config get featureFlags.experimental # Returns nested value
|
||||
openspec config set featureFlags.newFlag true
|
||||
```
|
||||
|
||||
### Type Coercion: Auto-detect with `--string` Override
|
||||
|
||||
**Decision:** Parse values automatically; provide `--string` flag to force string storage.
|
||||
|
||||
**Rationale:**
|
||||
- Most intuitive for common cases (`true`, `false`, `123`)
|
||||
- Explicit override for edge cases (storing literal string "true")
|
||||
- Follows npm/yarn config patterns
|
||||
|
||||
**Coercion rules:**
|
||||
| Input | Stored As |
|
||||
|-------|-----------|
|
||||
| `true`, `false` | boolean |
|
||||
| Numeric string (`123`, `3.14`) | number |
|
||||
| Everything else | string |
|
||||
| Any value with `--string` | string |
|
||||
|
||||
### Output Format: Raw by Default
|
||||
|
||||
**Decision:** `get` prints raw value only. `list` prints YAML-like format by default, JSON with `--json`.
|
||||
|
||||
**Rationale:**
|
||||
- Raw output enables piping: `VAR=$(openspec config get key)`
|
||||
- YAML-like is human-readable for inspection
|
||||
- JSON for automation/scripting
|
||||
|
||||
### Schema Validation: Zod with Unknown Field Passthrough
|
||||
|
||||
**Decision:** Use zod for validation but preserve unknown fields per `global-config` spec.
|
||||
|
||||
**Rationale:**
|
||||
- Type safety for known fields
|
||||
- Forward compatibility (old CLI doesn't break new config)
|
||||
- Follows existing `global-config` spec requirement
|
||||
|
||||
### Reserved Flag: `--scope`
|
||||
|
||||
**Decision:** Reserve `--scope global|project` but only implement `global` initially.
|
||||
|
||||
**Rationale:**
|
||||
- Avoids breaking change if project-local config is added later
|
||||
- Clear error message if someone tries `--scope project`
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Dot notation conflicts with keys containing dots | Rare in practice; document limitation |
|
||||
| Type coercion surprises | `--string` escape hatch; document rules |
|
||||
| $EDITOR not set | Check and provide helpful error message |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None - design is straightforward.
|
||||
@@ -0,0 +1,60 @@
|
||||
## Why
|
||||
|
||||
Users need a way to view and modify their global OpenSpec settings without manually editing JSON files. The `global-config` spec provides the foundation, but there's no user-facing interface to interact with the config. A dedicated `openspec config` command provides discoverability and ease of use.
|
||||
|
||||
## What Changes
|
||||
|
||||
Add `openspec config` subcommand with the following operations:
|
||||
|
||||
```bash
|
||||
openspec config path # Show config file location
|
||||
openspec config list [--json] # Show all current settings
|
||||
openspec config get <key> # Get a specific value (raw, scriptable)
|
||||
openspec config set <key> <value> [--string] # Set a value (auto-coerce types)
|
||||
openspec config unset <key> # Remove a key (revert to default)
|
||||
openspec config reset --all [-y] # Reset everything to defaults
|
||||
openspec config edit # Open config in $EDITOR
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
- **Key naming**: Use camelCase to match JSON structure (e.g., `featureFlags.someFlag`)
|
||||
- **Nested keys**: Support dot notation for nested access
|
||||
- **Type coercion**: Auto-detect types by default; `--string` flag forces string storage
|
||||
- **Scriptable output**: `get` prints raw value only (no labels) for easy piping
|
||||
- **Zod validation**: Use zod for config schema validation and type safety
|
||||
- **Future-proofing**: Reserve `--scope global|project` flag for potential project-local config
|
||||
|
||||
**Example usage:**
|
||||
```bash
|
||||
$ openspec config path
|
||||
/Users/me/.config/openspec/config.json
|
||||
|
||||
$ openspec config list
|
||||
featureFlags: {}
|
||||
|
||||
$ openspec config set featureFlags.enableTelemetry false
|
||||
Set featureFlags.enableTelemetry = false
|
||||
|
||||
$ openspec config get featureFlags.enableTelemetry
|
||||
false
|
||||
|
||||
$ openspec config list --json
|
||||
{
|
||||
"featureFlags": {}
|
||||
}
|
||||
|
||||
$ openspec config unset featureFlags.enableTelemetry
|
||||
Unset featureFlags.enableTelemetry (reverted to default)
|
||||
|
||||
$ openspec config edit
|
||||
# Opens $EDITOR with config.json
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `cli-config` capability
|
||||
- Affected code:
|
||||
- New `src/commands/config.ts`
|
||||
- New `src/core/config-schema.ts` (zod schema)
|
||||
- Update CLI entry point to register config command
|
||||
- Dependencies: Requires `global-config` spec (already implemented)
|
||||
@@ -0,0 +1,213 @@
|
||||
# cli-config Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide a CLI interface for viewing and modifying global OpenSpec configuration. Enables users to manage settings without manually editing JSON files, with support for scripting and automation.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Command Structure
|
||||
|
||||
The config command SHALL provide subcommands for all configuration operations.
|
||||
|
||||
#### Scenario: Available subcommands
|
||||
|
||||
- **WHEN** user executes `openspec config --help`
|
||||
- **THEN** display available subcommands:
|
||||
- `path` - Show config file location
|
||||
- `list` - Show all current settings
|
||||
- `get <key>` - Get a specific value
|
||||
- `set <key> <value>` - Set a value
|
||||
- `unset <key>` - Remove a key (revert to default)
|
||||
- `reset` - Reset configuration to defaults
|
||||
- `edit` - Open config in editor
|
||||
|
||||
### Requirement: Config Path
|
||||
|
||||
The config command SHALL display the config file location.
|
||||
|
||||
#### Scenario: Show config path
|
||||
|
||||
- **WHEN** user executes `openspec config path`
|
||||
- **THEN** print the absolute path to the config file
|
||||
- **AND** exit with code 0
|
||||
|
||||
### Requirement: Config List
|
||||
|
||||
The config command SHALL display all current configuration values.
|
||||
|
||||
#### Scenario: List config in human-readable format
|
||||
|
||||
- **WHEN** user executes `openspec config list`
|
||||
- **THEN** display all config values in YAML-like format
|
||||
- **AND** show nested objects with indentation
|
||||
|
||||
#### Scenario: List config as JSON
|
||||
|
||||
- **WHEN** user executes `openspec config list --json`
|
||||
- **THEN** output the complete config as valid JSON
|
||||
- **AND** output only JSON (no additional text)
|
||||
|
||||
### Requirement: Config Get
|
||||
|
||||
The config command SHALL retrieve specific configuration values.
|
||||
|
||||
#### Scenario: Get top-level key
|
||||
|
||||
- **WHEN** user executes `openspec config get <key>` with a valid top-level key
|
||||
- **THEN** print the raw value only (no labels or formatting)
|
||||
- **AND** exit with code 0
|
||||
|
||||
#### Scenario: Get nested key with dot notation
|
||||
|
||||
- **WHEN** user executes `openspec config get featureFlags.someFlag`
|
||||
- **THEN** traverse the nested structure using dot notation
|
||||
- **AND** print the value at that path
|
||||
|
||||
#### Scenario: Get non-existent key
|
||||
|
||||
- **WHEN** user executes `openspec config get <key>` with a key that does not exist
|
||||
- **THEN** print nothing (empty output)
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Get object value
|
||||
|
||||
- **WHEN** user executes `openspec config get <key>` where the value is an object
|
||||
- **THEN** print the object as JSON
|
||||
|
||||
### Requirement: Config Set
|
||||
|
||||
The config command SHALL set configuration values with automatic type coercion.
|
||||
|
||||
#### Scenario: Set string value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value>`
|
||||
- **AND** value does not match boolean or number patterns
|
||||
- **THEN** store value as a string
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Set boolean value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> true` or `openspec config set <key> false`
|
||||
- **THEN** store value as boolean (not string)
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Set numeric value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value>`
|
||||
- **AND** value is a valid number (integer or float)
|
||||
- **THEN** store value as number (not string)
|
||||
|
||||
#### Scenario: Force string with --string flag
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value> --string`
|
||||
- **THEN** store value as string regardless of content
|
||||
- **AND** this allows storing literal "true" or "123" as strings
|
||||
|
||||
#### Scenario: Set nested key
|
||||
|
||||
- **WHEN** user executes `openspec config set featureFlags.newFlag true`
|
||||
- **THEN** create intermediate objects if they don't exist
|
||||
- **AND** set the value at the nested path
|
||||
|
||||
### Requirement: Config Unset
|
||||
|
||||
The config command SHALL remove configuration overrides.
|
||||
|
||||
#### Scenario: Unset existing key
|
||||
|
||||
- **WHEN** user executes `openspec config unset <key>`
|
||||
- **AND** the key exists in the config
|
||||
- **THEN** remove the key from the config file
|
||||
- **AND** the value reverts to its default
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Unset non-existent key
|
||||
|
||||
- **WHEN** user executes `openspec config unset <key>`
|
||||
- **AND** the key does not exist in the config
|
||||
- **THEN** display message indicating key was not set
|
||||
- **AND** exit with code 0
|
||||
|
||||
### Requirement: Config Reset
|
||||
|
||||
The config command SHALL reset configuration to defaults.
|
||||
|
||||
#### Scenario: Reset all with confirmation
|
||||
|
||||
- **WHEN** user executes `openspec config reset --all`
|
||||
- **THEN** prompt for confirmation before proceeding
|
||||
- **AND** if confirmed, delete the config file or reset to defaults
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Reset all with -y flag
|
||||
|
||||
- **WHEN** user executes `openspec config reset --all -y`
|
||||
- **THEN** reset without prompting for confirmation
|
||||
|
||||
#### Scenario: Reset without --all flag
|
||||
|
||||
- **WHEN** user executes `openspec config reset` without `--all`
|
||||
- **THEN** display error indicating `--all` is required
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Config Edit
|
||||
|
||||
The config command SHALL open the config file in the user's editor.
|
||||
|
||||
#### Scenario: Open editor successfully
|
||||
|
||||
- **WHEN** user executes `openspec config edit`
|
||||
- **AND** `$EDITOR` or `$VISUAL` environment variable is set
|
||||
- **THEN** open the config file in that editor
|
||||
- **AND** create the config file with defaults if it doesn't exist
|
||||
- **AND** wait for the editor to close before returning
|
||||
|
||||
#### Scenario: No editor configured
|
||||
|
||||
- **WHEN** user executes `openspec config edit`
|
||||
- **AND** neither `$EDITOR` nor `$VISUAL` is set
|
||||
- **THEN** display error message suggesting to set `$EDITOR`
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Key Naming Convention
|
||||
|
||||
The config command SHALL use camelCase keys matching the JSON structure.
|
||||
|
||||
#### Scenario: Keys match JSON structure
|
||||
|
||||
- **WHEN** accessing configuration keys via CLI
|
||||
- **THEN** use camelCase matching the actual JSON property names
|
||||
- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`)
|
||||
|
||||
### Requirement: Schema Validation
|
||||
|
||||
The config command SHALL validate configuration writes against the config schema using zod, while allowing unknown fields for forward compatibility.
|
||||
|
||||
#### Scenario: Unknown key accepted
|
||||
|
||||
- **WHEN** user executes `openspec config set someFutureKey 123`
|
||||
- **THEN** the value is saved successfully
|
||||
- **AND** exit with code 0
|
||||
|
||||
#### Scenario: Invalid feature flag value rejected
|
||||
|
||||
- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean`
|
||||
- **THEN** display a descriptive error message
|
||||
- **AND** do not modify the config file
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Reserved Scope Flag
|
||||
|
||||
The config command SHALL reserve the `--scope` flag for future extensibility.
|
||||
|
||||
#### Scenario: Scope flag defaults to global
|
||||
|
||||
- **WHEN** user executes any config command without `--scope`
|
||||
- **THEN** operate on global configuration (default behavior)
|
||||
|
||||
#### Scenario: Project scope not yet implemented
|
||||
|
||||
- **WHEN** user executes `openspec config --scope project <subcommand>`
|
||||
- **THEN** display error message: "Project-local config is not yet implemented"
|
||||
- **AND** exit with code 1
|
||||
@@ -0,0 +1,28 @@
|
||||
## 1. Core Infrastructure
|
||||
|
||||
- [x] 1.1 Create zod schema for global config in `src/core/config-schema.ts`
|
||||
- [x] 1.2 Add utility functions for dot-notation key access (get/set nested values)
|
||||
- [x] 1.3 Add type coercion logic (auto-detect boolean/number/string)
|
||||
|
||||
## 2. Config Command Implementation
|
||||
|
||||
- [x] 2.1 Create `src/commands/config.ts` with Commander.js subcommands
|
||||
- [x] 2.2 Implement `config path` subcommand
|
||||
- [x] 2.3 Implement `config list` subcommand with `--json` flag
|
||||
- [x] 2.4 Implement `config get <key>` subcommand (raw output)
|
||||
- [x] 2.5 Implement `config set <key> <value>` with `--string` flag
|
||||
- [x] 2.6 Implement `config unset <key>` subcommand
|
||||
- [x] 2.7 Implement `config reset --all` with `-y` confirmation flag
|
||||
- [x] 2.8 Implement `config edit` subcommand (spawn $EDITOR)
|
||||
|
||||
## 3. Integration
|
||||
|
||||
- [x] 3.1 Register config command in CLI entry point
|
||||
- [x] 3.2 Update shell completion registry to include config subcommands
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- [x] 4.1 Manual testing of all subcommands
|
||||
- [x] 4.2 Verify zod validation rejects invalid keys/values
|
||||
- [x] 4.3 Test nested key access with dot notation
|
||||
- [x] 4.4 Test type coercion edge cases (true/false, numbers, strings)
|
||||
@@ -0,0 +1,197 @@
|
||||
## Context
|
||||
|
||||
This implements "Slice 1: What's Ready?" from the artifact POC analysis. The core insight is using the filesystem as a database - artifact completion is detected by file existence, making the system stateless and version-control friendly.
|
||||
|
||||
This module will coexist with the current OpenSpec system as a parallel capability, potentially enabling future migration or integration.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Pure dependency graph logic with no side effects
|
||||
- Stateless state detection (rescan filesystem each query)
|
||||
- Support glob patterns for multi-file artifacts (e.g., `specs/*.md`)
|
||||
- Load artifact definitions from YAML schemas
|
||||
- Calculate topological build order
|
||||
- Determine "ready" artifacts based on dependency completion
|
||||
|
||||
**Non-Goals:**
|
||||
- CLI commands (Slice 4)
|
||||
- Multi-change management (Slice 2)
|
||||
- Template resolution and enrichment (Slice 3)
|
||||
- Agent integration or Claude commands
|
||||
- Replacing existing OpenSpec functionality
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Filesystem as Database
|
||||
Use file existence for state detection rather than a separate state file.
|
||||
|
||||
**Rationale:**
|
||||
- Stateless - no state corruption possible
|
||||
- Git-friendly - state derived from committed files
|
||||
- Simple - no sync issues between state file and actual files
|
||||
|
||||
**Alternatives considered:**
|
||||
- JSON/SQLite state file: More complex, sync issues, not git-friendly
|
||||
- Git metadata: Too coupled to git, complex implementation
|
||||
|
||||
### Decision: Kahn's Algorithm for Topological Sort
|
||||
Use Kahn's algorithm for computing build order.
|
||||
|
||||
**Rationale:**
|
||||
- Well-understood, O(V+E) complexity
|
||||
- Naturally detects cycles during execution
|
||||
- Produces a stable, deterministic order
|
||||
|
||||
### Decision: Glob Pattern Support
|
||||
Support glob patterns like `specs/*.md` in artifact `generates` field.
|
||||
|
||||
**Rationale:**
|
||||
- Allows multiple files to satisfy a single artifact requirement
|
||||
- Common pattern for spec directories with multiple files
|
||||
- Uses standard glob syntax
|
||||
|
||||
### Decision: Immutable Completed Set
|
||||
Represent completion state as an immutable Set of completed artifact IDs.
|
||||
|
||||
**Rationale:**
|
||||
- Functional style, easier to reason about
|
||||
- State derived fresh each query, no mutation needed
|
||||
- Clear separation between graph structure and runtime state
|
||||
- Filesystem can only detect binary existence (complete vs not complete)
|
||||
|
||||
**Note:** `inProgress` and `failed` states are deferred to future slices. They would require external state tracking (e.g., a status file) since file existence alone cannot distinguish these states.
|
||||
|
||||
### Decision: Zod for Schema Validation
|
||||
Use Zod for validating YAML schema structure and deriving TypeScript types.
|
||||
|
||||
**Rationale:**
|
||||
- Already a project dependency (v4.0.17) used in `src/core/schemas/`
|
||||
- Type inference via `z.infer<>` - single source of truth for types
|
||||
- Runtime validation with detailed error messages
|
||||
- Consistent with existing project patterns (`base.schema.ts`, `config-schema.ts`)
|
||||
|
||||
**Alternatives considered:**
|
||||
- Manual validation: More code, error-prone, no type inference
|
||||
- JSON Schema: Would require additional dependency, less TypeScript integration
|
||||
- io-ts: Not already in project, steeper learning curve
|
||||
|
||||
### Decision: Two-Level Schema Resolution
|
||||
Schemas resolve from global user data directory, falling back to package built-ins.
|
||||
|
||||
**Resolution order:**
|
||||
1. `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml` - Global user override
|
||||
2. `<package>/schemas/<name>.yaml` - Built-in defaults
|
||||
|
||||
**Rationale:**
|
||||
- Follows XDG Base Directory Specification (schemas are data, not config)
|
||||
- Mirrors existing `getGlobalConfigDir()` pattern in `src/core/global-paths.ts`
|
||||
- Built-ins baked into package, never auto-copied
|
||||
- Users customize by creating files in global data dir
|
||||
- Simple - no project-level overrides (can add later if needed)
|
||||
|
||||
**XDG compliance:**
|
||||
- Uses `XDG_DATA_HOME` env var when set (all platforms)
|
||||
- Unix/macOS fallback: `~/.local/share/openspec/`
|
||||
- Windows fallback: `%LOCALAPPDATA%/openspec/`
|
||||
|
||||
**Alternatives considered:**
|
||||
- Project-level overrides: Added complexity, not needed initially
|
||||
- Auto-copy to user space: Creates drift, harder to update defaults
|
||||
- Config directory (`XDG_CONFIG_HOME`): Schemas are workflow definitions (data), not user preferences (config)
|
||||
|
||||
### Decision: Template Field Parsed But Not Resolved
|
||||
The `template` field is required in schema YAML for completeness, but template resolution is deferred to Slice 3.
|
||||
|
||||
**Rationale:**
|
||||
- Slice 1 focuses on "What's Ready?" - dependency and completion queries only
|
||||
- Template paths are validated syntactically (non-empty string) but not resolved
|
||||
- Keeps Slice 1 focused and independently testable
|
||||
|
||||
### Decision: Cycle Error Format
|
||||
Cycle errors list all artifact IDs in the cycle for easy debugging.
|
||||
|
||||
**Format:** `"Cyclic dependency detected: A → B → C → A"`
|
||||
|
||||
**Rationale:**
|
||||
- Shows the full cycle path, not just that a cycle exists
|
||||
- Actionable - developer can see exactly which artifacts to fix
|
||||
- Consistent with Kahn's algorithm which naturally identifies cycle participants
|
||||
|
||||
## Data Structures
|
||||
|
||||
**Zod Schemas (source of truth):**
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Artifact definition schema
|
||||
export const ArtifactSchema = z.object({
|
||||
id: z.string().min(1, 'Artifact ID is required'),
|
||||
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
|
||||
description: z.string(),
|
||||
template: z.string(), // path to template file
|
||||
requires: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
// Full schema YAML structure
|
||||
export const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1, 'Schema name is required'),
|
||||
version: z.number().int().positive(),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1, 'At least one artifact required'),
|
||||
});
|
||||
|
||||
// Derived TypeScript types
|
||||
export type Artifact = z.infer<typeof ArtifactSchema>;
|
||||
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
|
||||
```
|
||||
|
||||
**Runtime State (not Zod - internal only):**
|
||||
|
||||
```typescript
|
||||
// Slice 1: Simple completion tracking via filesystem
|
||||
type CompletedSet = Set<string>;
|
||||
|
||||
// Return type for blocked query
|
||||
interface BlockedArtifacts {
|
||||
[artifactId: string]: string[]; // artifact → list of unmet dependencies
|
||||
}
|
||||
|
||||
interface ArtifactGraphResult {
|
||||
completed: string[];
|
||||
ready: string[];
|
||||
blocked: BlockedArtifacts;
|
||||
buildOrder: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/core/artifact-graph/
|
||||
├── index.ts # Public exports
|
||||
├── types.ts # Zod schemas and type definitions
|
||||
├── graph.ts # ArtifactGraph class
|
||||
├── state.ts # State detection logic
|
||||
├── resolver.ts # Schema resolution (global → built-in)
|
||||
└── schemas/ # Built-in schema definitions (package level)
|
||||
├── spec-driven.yaml # Default: proposal → specs → design → tasks
|
||||
└── tdd.yaml # Alternative: tests → implementation → docs
|
||||
```
|
||||
|
||||
**Schema Resolution Paths:**
|
||||
- Global user override: `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml`
|
||||
- Package built-in: `src/core/artifact-graph/schemas/<name>.yaml` (bundled with package)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Glob pattern edge cases | Use well-tested glob library (fast-glob or similar) |
|
||||
| Cycle detection | Kahn's algorithm naturally fails on cycles; provide clear error |
|
||||
| Schema evolution | Version field in schema, validate on load |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None - all questions resolved in Decisions section.
|
||||
@@ -0,0 +1,18 @@
|
||||
## Why
|
||||
|
||||
The current OpenSpec system relies on conventions and AI inference for artifact ordering. A formal artifact graph with dependency awareness would enable deterministic "what's ready?" queries, making the system more predictable and enabling future features like automated pipeline execution.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `ArtifactGraph` class to model artifacts as a DAG with dependency relationships
|
||||
- Add `ArtifactState` type to track completion status (completed, in_progress, failed)
|
||||
- Add filesystem-based state detection using file existence and glob patterns
|
||||
- Add schema YAML parser to load artifact definitions
|
||||
- Implement topological sort (Kahn's algorithm) for build order calculation
|
||||
- Add `getNextArtifacts()` to find artifacts ready for creation
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `artifact-graph` capability
|
||||
- Affected code: `src/core/artifact-graph/` (new directory)
|
||||
- No changes to existing functionality - this is a parallel module
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Schema Loading
|
||||
The system SHALL load artifact graph definitions from YAML schema files.
|
||||
|
||||
#### Scenario: Valid schema loaded
|
||||
- **WHEN** a valid schema YAML file is provided
|
||||
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
|
||||
|
||||
#### Scenario: Invalid schema rejected
|
||||
- **WHEN** a schema YAML file is missing required fields
|
||||
- **THEN** the system throws an error with a descriptive message
|
||||
|
||||
#### Scenario: Cyclic dependencies detected
|
||||
- **WHEN** a schema contains cyclic artifact dependencies
|
||||
- **THEN** the system throws an error listing the artifact IDs in the cycle
|
||||
|
||||
#### Scenario: Invalid dependency reference
|
||||
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
|
||||
- **THEN** the system throws an error identifying the invalid reference
|
||||
|
||||
#### Scenario: Duplicate artifact IDs rejected
|
||||
- **WHEN** a schema contains multiple artifacts with the same ID
|
||||
- **THEN** the system throws an error identifying the duplicate
|
||||
|
||||
### Requirement: Build Order Calculation
|
||||
The system SHALL compute a valid topological build order for artifacts.
|
||||
|
||||
#### Scenario: Linear dependency chain
|
||||
- **WHEN** artifacts form a linear chain (A → B → C)
|
||||
- **THEN** getBuildOrder() returns [A, B, C]
|
||||
|
||||
#### Scenario: Diamond dependency
|
||||
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
|
||||
- **THEN** getBuildOrder() returns A before B and C, and D last
|
||||
|
||||
#### Scenario: Independent artifacts
|
||||
- **WHEN** artifacts have no dependencies
|
||||
- **THEN** getBuildOrder() returns them in a stable order
|
||||
|
||||
### Requirement: State Detection
|
||||
The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
|
||||
#### Scenario: Simple file exists
|
||||
- **WHEN** an artifact generates "proposal.md" and the file exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Simple file missing
|
||||
- **WHEN** an artifact generates "proposal.md" and the file does not exist
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Glob pattern with files
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Glob pattern empty
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Missing change directory
|
||||
- **WHEN** the change directory does not exist
|
||||
- **THEN** all artifacts are marked as not completed (empty state)
|
||||
|
||||
### Requirement: Ready Artifact Query
|
||||
The system SHALL identify which artifacts are ready to be created based on dependency completion.
|
||||
|
||||
#### Scenario: Root artifacts ready initially
|
||||
- **WHEN** no artifacts are completed
|
||||
- **THEN** getNextArtifacts() returns artifacts with no dependencies
|
||||
|
||||
#### Scenario: Dependent artifact becomes ready
|
||||
- **WHEN** an artifact's dependencies are all completed
|
||||
- **THEN** getNextArtifacts() includes that artifact
|
||||
|
||||
#### Scenario: Blocked artifacts excluded
|
||||
- **WHEN** an artifact has uncompleted dependencies
|
||||
- **THEN** getNextArtifacts() does not include that artifact
|
||||
|
||||
### Requirement: Completion Check
|
||||
The system SHALL determine when all artifacts in a graph are complete.
|
||||
|
||||
#### Scenario: All complete
|
||||
- **WHEN** all artifacts in the graph are in the completed set
|
||||
- **THEN** isComplete() returns true
|
||||
|
||||
#### Scenario: Partially complete
|
||||
- **WHEN** some artifacts in the graph are not completed
|
||||
- **THEN** isComplete() returns false
|
||||
|
||||
### Requirement: Blocked Query
|
||||
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
|
||||
|
||||
#### Scenario: Artifact blocked by single dependency
|
||||
- **WHEN** artifact B requires artifact A and A is not complete
|
||||
- **THEN** getBlocked() returns `{ B: ['A'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by multiple dependencies
|
||||
- **WHEN** artifact C requires A and B, and only A is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['B'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by all dependencies
|
||||
- **WHEN** artifact C requires A and B, and neither is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
|
||||
@@ -0,0 +1,61 @@
|
||||
## 1. Type Definitions
|
||||
- [x] 1.1 Create `src/core/artifact-graph/types.ts` with Zod schemas (`ArtifactSchema`, `SchemaYamlSchema`) and inferred types via `z.infer<>`
|
||||
- [x] 1.2 Define `CompletedSet` (Set<string>), `BlockedArtifacts`, and `ArtifactGraphResult` types for runtime state
|
||||
|
||||
## 2. Schema Parser
|
||||
- [x] 2.1 Create `src/core/artifact-graph/schema.ts` with YAML loading and Zod validation via `.safeParse()`
|
||||
- [x] 2.2 Implement dependency reference validation (ensure `requires` references valid artifact IDs)
|
||||
- [x] 2.3 Implement duplicate artifact ID detection
|
||||
- [x] 2.4 Add cycle detection during schema load (error format: "Cyclic dependency detected: A → B → C → A")
|
||||
|
||||
## 3. Artifact Graph Core
|
||||
- [x] 3.1 Create `src/core/artifact-graph/graph.ts` with ArtifactGraph class
|
||||
- [x] 3.2 Implement `fromYaml(path)` - load graph from schema file
|
||||
- [x] 3.3 Implement `getBuildOrder()` - topological sort via Kahn's algorithm
|
||||
- [x] 3.4 Implement `getArtifact(id)` - retrieve single artifact definition
|
||||
- [x] 3.5 Implement `getAllArtifacts()` - list all artifacts
|
||||
|
||||
## 4. State Detection
|
||||
- [x] 4.1 Create `src/core/artifact-graph/state.ts` with state detection logic
|
||||
- [x] 4.2 Implement file existence checking for simple paths
|
||||
- [x] 4.3 Implement glob pattern matching for multi-file artifacts
|
||||
- [x] 4.4 Implement `detectCompleted(graph, changeDir)` - scan filesystem and return CompletedSet
|
||||
- [x] 4.5 Handle missing changeDir gracefully (return empty CompletedSet)
|
||||
|
||||
## 5. Ready Calculation
|
||||
- [x] 5.1 Implement `getNextArtifacts(graph, completed)` - find artifacts with all deps completed
|
||||
- [x] 5.2 Implement `isComplete(graph, completed)` - check if all artifacts done
|
||||
- [x] 5.3 Implement `getBlocked(graph, completed)` - return BlockedArtifacts map (artifact → unmet deps)
|
||||
|
||||
## 6. Schema Resolution
|
||||
- [x] 6.1 Create `src/core/artifact-graph/resolver.ts` with schema resolution logic
|
||||
- [x] 6.2 Add `getGlobalDataDir()` to `src/core/global-config.ts` (XDG_DATA_HOME with platform fallbacks)
|
||||
- [x] 6.3 Implement `resolveSchema(name)` - global (`${XDG_DATA_HOME}/openspec/schemas/`) → built-in fallback
|
||||
|
||||
## 7. Built-in Schemas
|
||||
- [x] 7.1 Create `src/core/artifact-graph/schemas/spec-driven.yaml` (default: proposal → specs → design → tasks)
|
||||
- [x] 7.2 Create `src/core/artifact-graph/schemas/tdd.yaml` (alternative: tests → implementation → docs)
|
||||
|
||||
## 8. Integration
|
||||
- [x] 8.1 Create `src/core/artifact-graph/index.ts` with public exports
|
||||
|
||||
## 9. Testing
|
||||
- [x] 9.1 Test: Parse valid schema YAML returns correct artifact graph
|
||||
- [x] 9.2 Test: Parse invalid schema (missing fields) throws descriptive error
|
||||
- [x] 9.3 Test: Duplicate artifact IDs throws error
|
||||
- [x] 9.4 Test: Invalid `requires` reference throws error identifying the invalid ID
|
||||
- [x] 9.5 Test: Cycle in schema throws error listing cycle path (e.g., "A → B → C → A")
|
||||
- [x] 9.6 Test: Compute build order returns correct topological ordering (linear chain)
|
||||
- [x] 9.7 Test: Compute build order handles diamond dependencies correctly
|
||||
- [x] 9.8 Test: Independent artifacts return in stable order
|
||||
- [x] 9.9 Test: Empty/missing changeDir returns empty CompletedSet
|
||||
- [x] 9.10 Test: File existence marks artifact as completed
|
||||
- [x] 9.11 Test: Glob pattern specs/*.md detected as complete when files exist
|
||||
- [x] 9.12 Test: Glob pattern with empty directory not marked complete
|
||||
- [x] 9.13 Test: getNextArtifacts returns only root artifacts when nothing completed
|
||||
- [x] 9.14 Test: getNextArtifacts includes artifact when all deps completed
|
||||
- [x] 9.15 Test: getBlocked returns artifact with all unmet dependencies listed
|
||||
- [x] 9.16 Test: isComplete() returns true when all artifacts completed
|
||||
- [x] 9.17 Test: isComplete() returns false when some artifacts incomplete
|
||||
- [x] 9.18 Test: Schema resolution finds global override before built-in
|
||||
- [x] 9.19 Test: Schema resolution falls back to built-in when no global
|
||||
@@ -0,0 +1,74 @@
|
||||
## Context
|
||||
|
||||
This is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically.
|
||||
|
||||
**Current state:** No programmatic way to create changes. Users must manually create directories.
|
||||
|
||||
**Proposed state:** Utility functions for change creation with name validation.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- **Add** `createChange()` function to create change directories
|
||||
- **Add** `validateChangeName()` function for kebab-case validation
|
||||
- **Enable** automation (Claude commands, scripts) to create changes
|
||||
|
||||
### Non-Goals
|
||||
- Refactor existing CLI commands (they work fine)
|
||||
- Create abstraction layers or manager classes
|
||||
- Change how `ListCommand` or `ChangeCommand` work
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Simple Utility Functions
|
||||
|
||||
**Choice**: Add functions to `src/utils/change-utils.ts` - no class.
|
||||
|
||||
```typescript
|
||||
// src/utils/change-utils.ts
|
||||
|
||||
export function validateChangeName(name: string): { valid: boolean; error?: string }
|
||||
|
||||
export async function createChange(
|
||||
projectRoot: string,
|
||||
name: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**Why**:
|
||||
- Simple, no abstraction overhead
|
||||
- Easy to test
|
||||
- Easy to import where needed
|
||||
- Matches existing utility patterns in `src/utils/`
|
||||
|
||||
**Alternatives considered**:
|
||||
- ChangeManager class: Rejected - over-engineered for 2 functions
|
||||
- Add to existing command: Rejected - mixes CLI with reusable logic
|
||||
|
||||
### Decision 2: Kebab-Case Validation Pattern
|
||||
|
||||
**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
|
||||
|
||||
Valid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor`
|
||||
Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth`
|
||||
|
||||
**Why**:
|
||||
- Filesystem-safe (no special characters)
|
||||
- URL-safe (for future web UI)
|
||||
- Consistent with existing change naming in repo
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
- `src/utils/change-utils.ts` - Utility functions
|
||||
- `src/utils/change-utils.test.ts` - Unit tests
|
||||
|
||||
### Modified Files
|
||||
- None
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Function might not cover all use cases | Start simple, extend if needed |
|
||||
| Naming conflicts with future work | Using clear, specific function names |
|
||||
@@ -0,0 +1,45 @@
|
||||
## Why
|
||||
|
||||
There's no programmatic way to create a new change directory. Users must manually:
|
||||
1. Create `openspec/changes/<name>/` directory
|
||||
2. Create a `proposal.md` file
|
||||
3. Hope they got the naming right
|
||||
|
||||
This is error-prone and blocks automation (e.g., Claude commands, scripts).
|
||||
|
||||
**This proposal adds:**
|
||||
1. `createChange(projectRoot, name)` - Create change directories programmatically
|
||||
2. `validateChangeName(name)` - Enforce kebab-case naming conventions
|
||||
|
||||
## What Changes
|
||||
|
||||
### New Utilities
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `createChange(projectRoot, name)` | Creates `openspec/changes/<name>/` directory |
|
||||
| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` |
|
||||
|
||||
### Name Validation Rules
|
||||
|
||||
Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
|
||||
|
||||
| Valid | Invalid |
|
||||
|-------|---------|
|
||||
| `add-auth` | `Add-Auth` (uppercase) |
|
||||
| `refactor-db` | `add auth` (spaces) |
|
||||
| `add-feature-2` | `add_auth` (underscores) |
|
||||
| `refactor` | `-add-auth` (leading hyphen) |
|
||||
|
||||
### Location
|
||||
|
||||
New file: `src/utils/change-utils.ts`
|
||||
|
||||
Simple utility functions - no class, no abstraction layer.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected specs**: None
|
||||
- **Affected code**: None (new utilities only)
|
||||
- **New files**: `src/utils/change-utils.ts`
|
||||
- **Breaking changes**: None
|
||||
@@ -0,0 +1,63 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Change Creation
|
||||
The system SHALL provide a function to create new change directories programmatically.
|
||||
|
||||
#### Scenario: Create change
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
|
||||
- **THEN** the system creates `openspec/changes/add-auth/` directory
|
||||
|
||||
#### Scenario: Duplicate change rejected
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
|
||||
- **THEN** the system throws an error indicating the change already exists
|
||||
|
||||
#### Scenario: Creates parent directories if needed
|
||||
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
|
||||
- **THEN** the system creates the full path including parent directories
|
||||
|
||||
#### Scenario: Invalid change name rejected
|
||||
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
|
||||
- **THEN** the system throws a validation error
|
||||
|
||||
### Requirement: Change Name Validation
|
||||
The system SHALL validate change names follow kebab-case conventions.
|
||||
|
||||
#### Scenario: Valid kebab-case name accepted
|
||||
- **WHEN** a change name like `add-user-auth` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Numeric suffixes accepted
|
||||
- **WHEN** a change name like `add-feature-2` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Single word accepted
|
||||
- **WHEN** a change name like `refactor` is validated
|
||||
- **THEN** validation returns `{ valid: true }`
|
||||
|
||||
#### Scenario: Uppercase characters rejected
|
||||
- **WHEN** a change name like `Add-Auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Spaces rejected
|
||||
- **WHEN** a change name like `add auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Underscores rejected
|
||||
- **WHEN** a change name like `add_auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Special characters rejected
|
||||
- **WHEN** a change name like `add-auth!` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Leading hyphen rejected
|
||||
- **WHEN** a change name like `-add-auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Trailing hyphen rejected
|
||||
- **WHEN** a change name like `add-auth-` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
|
||||
#### Scenario: Consecutive hyphens rejected
|
||||
- **WHEN** a change name like `add--auth` is validated
|
||||
- **THEN** validation returns `{ valid: false, error: "..." }`
|
||||
@@ -0,0 +1,30 @@
|
||||
## Phase 1: Implement Name Validation
|
||||
|
||||
- [x] 1.1 Create `src/utils/change-utils.ts`
|
||||
- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern
|
||||
- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
|
||||
- [x] 1.4 Return `{ valid: boolean; error?: string }`
|
||||
- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)
|
||||
- [x] 1.6 Add test: uppercase rejected
|
||||
- [x] 1.7 Add test: spaces rejected
|
||||
- [x] 1.8 Add test: underscores rejected
|
||||
- [x] 1.9 Add test: special characters rejected
|
||||
- [x] 1.10 Add test: leading/trailing hyphens rejected
|
||||
- [x] 1.11 Add test: consecutive hyphens rejected
|
||||
|
||||
## Phase 2: Implement Change Creation
|
||||
|
||||
- [x] 2.1 Implement `createChange(projectRoot, name)`
|
||||
- [x] 2.2 Validate name before creating
|
||||
- [x] 2.3 Create parent directories if needed (`openspec/changes/`)
|
||||
- [x] 2.4 Throw if change already exists
|
||||
- [x] 2.5 Add test: creates directory
|
||||
- [x] 2.6 Add test: duplicate change throws error
|
||||
- [x] 2.7 Add test: invalid name throws validation error
|
||||
- [x] 2.8 Add test: creates parent directories if needed
|
||||
|
||||
## Phase 3: Integration
|
||||
|
||||
- [x] 3.1 Export functions from `src/utils/index.ts`
|
||||
- [x] 3.2 Add JSDoc comments
|
||||
- [x] 3.3 Run all tests to verify no regressions
|
||||
@@ -0,0 +1,112 @@
|
||||
## Context
|
||||
|
||||
Slice 4 of the artifact workflow POC. The core functionality (ArtifactGraph, InstructionLoader, change-utils) is complete. This slice adds CLI commands to expose the artifact workflow to users.
|
||||
|
||||
**Key constraint**: This is experimental. Commands must be isolated for easy removal if the feature doesn't work out.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- **Goals:**
|
||||
- Expose artifact workflow status and instructions via CLI
|
||||
- Provide fluid UX with top-level verb commands
|
||||
- Support both human-readable and JSON output
|
||||
- Enable agents to programmatically query workflow state
|
||||
- Keep implementation isolated for easy removal
|
||||
|
||||
- **Non-Goals:**
|
||||
- Interactive artifact creation wizards (future work)
|
||||
- Schema management commands (deferred)
|
||||
- Auto-detection of active change (CLI is deterministic, agents infer)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Command Structure: Top-Level Verbs
|
||||
|
||||
Commands are top-level for maximum fluidity:
|
||||
|
||||
```
|
||||
openspec status --change <id>
|
||||
openspec next --change <id>
|
||||
openspec instructions <artifact> --change <id>
|
||||
openspec templates [--schema <name>]
|
||||
openspec new change <name>
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Most fluid UX - fewest keystrokes
|
||||
- Commands are unique enough to avoid conflicts
|
||||
- Simple mental model for users
|
||||
|
||||
**Trade-off accepted:** Slight namespace pollution, but commands are distinct and can be removed cleanly.
|
||||
|
||||
### Experimental Isolation
|
||||
|
||||
All artifact workflow commands are implemented in a single file:
|
||||
|
||||
```
|
||||
src/commands/artifact-workflow.ts
|
||||
```
|
||||
|
||||
**To remove the feature:**
|
||||
1. Delete `src/commands/artifact-workflow.ts`
|
||||
2. Remove ~5 lines from `src/cli/index.ts`
|
||||
|
||||
No other files touched, no risk to stable functionality.
|
||||
|
||||
### Deterministic CLI with Explicit `--change`
|
||||
|
||||
All change-specific commands require `--change <id>`:
|
||||
|
||||
```bash
|
||||
openspec status --change add-auth # explicit, works
|
||||
openspec status # error: missing --change
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- CLI is pure, testable, no hidden state
|
||||
- Agents infer change from conversation and pass explicitly
|
||||
- No config file tracking "active change"
|
||||
- Consistent with POC design philosophy
|
||||
|
||||
### New Change Command Structure
|
||||
|
||||
Creating changes uses explicit subcommand:
|
||||
|
||||
```bash
|
||||
openspec new change add-feature
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- `openspec new <name>` is ambiguous (new what?)
|
||||
- `openspec new change <name>` is clear and extensible
|
||||
- Can add `openspec new spec <name>` later if needed
|
||||
|
||||
### Output Formats
|
||||
|
||||
- **Default**: Human-readable text with visual indicators
|
||||
- Status: `[x]` done, `[ ]` ready, `[-]` blocked
|
||||
- Colors: green (done), yellow (ready), red (blocked)
|
||||
- **JSON** (`--json`): Machine-readable for scripts and agents
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Missing `--change`: Error listing available changes
|
||||
- Unknown change: Error with suggestion
|
||||
- Unknown artifact: Error listing valid artifacts
|
||||
- Missing schema: Error with schema resolution details
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Top-level commands pollute namespace | Commands are distinct; isolated for easy removal |
|
||||
| `status` confused with git | Context (`--change`) makes it clear |
|
||||
| Feature doesn't work out | Single file deletion removes everything |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All commands in `src/commands/artifact-workflow.ts`
|
||||
- Imports from `src/core/artifact-graph/` for all operations
|
||||
- Uses `getActiveChangeIds()` from `item-discovery.ts` for change listing
|
||||
- Follows existing CLI patterns (ora spinners, commander.js options)
|
||||
- Help text marks commands as "Experimental"
|
||||
@@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
The ArtifactGraph (Slice 1) and InstructionLoader (Slice 3) provide programmatic APIs for artifact-based workflow management. Users currently have no CLI interface to:
|
||||
- See artifact completion status for a change
|
||||
- Discover what artifacts are ready to create
|
||||
- Get enriched instructions for creating artifacts
|
||||
- Create new changes with proper validation
|
||||
|
||||
This proposal adds CLI commands that expose the artifact workflow functionality to users and agents.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **NEW**: `openspec status --change <id>` shows artifact completion state
|
||||
- **NEW**: `openspec next --change <id>` shows artifacts ready to create
|
||||
- **NEW**: `openspec instructions <artifact> --change <id>` outputs enriched template
|
||||
- **NEW**: `openspec templates [--schema <name>]` shows resolved template paths
|
||||
- **NEW**: `openspec new change <name>` creates a new change directory
|
||||
|
||||
All commands are top-level for fluid UX. They integrate with existing core modules:
|
||||
- Uses `loadChangeContext()`, `formatChangeStatus()`, `generateInstructions()` from instruction-loader
|
||||
- Uses `ArtifactGraph`, `detectCompleted()` from artifact-graph
|
||||
- Uses `createChange()`, `validateChangeName()` from change-utils
|
||||
|
||||
**Experimental isolation**: All commands are implemented in a single file (`src/commands/artifact-workflow.ts`) for easy removal if the feature doesn't work out. Help text marks them as experimental.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: NEW `cli-artifact-workflow` capability
|
||||
- Affected code:
|
||||
- `src/cli/index.ts` - register new commands
|
||||
- `src/commands/artifact-workflow.ts` - new command implementations
|
||||
- No changes to existing commands or specs
|
||||
- Builds on completed Slice 1, 2, and 3 implementations
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# cli-artifact-workflow Specification
|
||||
|
||||
## Purpose
|
||||
CLI commands for artifact workflow operations, exposing the artifact graph and instruction loader functionality to users and agents. Commands are top-level for fluid UX and implemented in isolation for easy removal.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Status Command
|
||||
The system SHALL display artifact completion status for a change.
|
||||
|
||||
#### Scenario: Show status with all states
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** the system displays each artifact with status indicator:
|
||||
- `[x]` for completed artifacts
|
||||
- `[ ]` for ready artifacts
|
||||
- `[-]` for blocked artifacts (with missing dependencies listed)
|
||||
|
||||
#### Scenario: Status shows completion summary
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
|
||||
|
||||
#### Scenario: Status JSON output
|
||||
- **WHEN** user runs `openspec status --change <id> --json`
|
||||
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
|
||||
|
||||
#### Scenario: Missing change parameter
|
||||
- **WHEN** user runs `openspec status` without `--change`
|
||||
- **THEN** the system displays an error with list of available changes
|
||||
|
||||
#### Scenario: Unknown change
|
||||
- **WHEN** user runs `openspec status --change unknown-id`
|
||||
- **THEN** the system displays an error indicating the change does not exist
|
||||
|
||||
### Requirement: Next Command
|
||||
The system SHALL show which artifacts are ready to be created.
|
||||
|
||||
#### Scenario: Show ready artifacts
|
||||
- **WHEN** user runs `openspec next --change <id>`
|
||||
- **THEN** the system lists artifacts whose dependencies are all satisfied
|
||||
|
||||
#### Scenario: No artifacts ready
|
||||
- **WHEN** all artifacts are either completed or blocked
|
||||
- **THEN** the system indicates no artifacts are ready (with explanation)
|
||||
|
||||
#### Scenario: All artifacts complete
|
||||
- **WHEN** all artifacts in the change are completed
|
||||
- **THEN** the system indicates the change is complete
|
||||
|
||||
#### Scenario: Next JSON output
|
||||
- **WHEN** user runs `openspec next --change <id> --json`
|
||||
- **THEN** the system outputs JSON array of ready artifact IDs
|
||||
|
||||
### Requirement: Instructions Command
|
||||
The system SHALL output enriched instructions for creating an artifact.
|
||||
|
||||
#### Scenario: Show enriched instructions
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
|
||||
- **THEN** the system outputs:
|
||||
- Artifact metadata (ID, output path, description)
|
||||
- Template content
|
||||
- Dependency status (done/missing)
|
||||
- Unlocked artifacts (what becomes available after completion)
|
||||
|
||||
#### Scenario: Instructions JSON output
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
|
||||
- **THEN** the system outputs JSON matching ArtifactInstructions interface
|
||||
|
||||
#### Scenario: Unknown artifact
|
||||
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
|
||||
- **THEN** the system displays an error listing valid artifact IDs for the schema
|
||||
|
||||
#### Scenario: Artifact with unmet dependencies
|
||||
- **WHEN** user requests instructions for a blocked artifact
|
||||
- **THEN** the system displays instructions with a warning about missing dependencies
|
||||
|
||||
### Requirement: Templates Command
|
||||
The system SHALL show resolved template paths for all artifacts in a schema.
|
||||
|
||||
#### Scenario: List template paths with default schema
|
||||
- **WHEN** user runs `openspec templates`
|
||||
- **THEN** the system displays each artifact with its resolved template path using the default schema
|
||||
|
||||
#### Scenario: List template paths with custom schema
|
||||
- **WHEN** user runs `openspec templates --schema tdd`
|
||||
- **THEN** the system displays template paths for the specified schema
|
||||
|
||||
#### Scenario: Templates JSON output
|
||||
- **WHEN** user runs `openspec templates --json`
|
||||
- **THEN** the system outputs JSON mapping artifact IDs to template paths
|
||||
|
||||
#### Scenario: Template resolution source
|
||||
- **WHEN** displaying template paths
|
||||
- **THEN** the system indicates whether each template is from user override or package built-in
|
||||
|
||||
### Requirement: New Change Command
|
||||
The system SHALL create new change directories with validation.
|
||||
|
||||
#### Scenario: Create valid change
|
||||
- **WHEN** user runs `openspec new change add-feature`
|
||||
- **THEN** the system creates `openspec/changes/add-feature/` directory
|
||||
|
||||
#### Scenario: Invalid change name
|
||||
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
|
||||
- **THEN** the system displays validation error with guidance
|
||||
|
||||
#### Scenario: Duplicate change name
|
||||
- **WHEN** user runs `openspec new change existing-change` for an existing change
|
||||
- **THEN** the system displays an error indicating the change already exists
|
||||
|
||||
#### Scenario: Create with description
|
||||
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
|
||||
- **THEN** the system creates the change directory with description in README.md
|
||||
|
||||
### Requirement: Schema Selection
|
||||
The system SHALL support custom schema selection for workflow commands.
|
||||
|
||||
#### Scenario: Default schema
|
||||
- **WHEN** user runs workflow commands without `--schema`
|
||||
- **THEN** the system uses the "spec-driven" schema
|
||||
|
||||
#### Scenario: Custom schema
|
||||
- **WHEN** user runs `openspec status --change <id> --schema tdd`
|
||||
- **THEN** the system uses the specified schema for artifact graph
|
||||
|
||||
#### Scenario: Unknown schema
|
||||
- **WHEN** user specifies an unknown schema
|
||||
- **THEN** the system displays an error listing available schemas
|
||||
|
||||
### Requirement: Output Formatting
|
||||
The system SHALL provide consistent output formatting.
|
||||
|
||||
#### Scenario: Color output
|
||||
- **WHEN** terminal supports colors
|
||||
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
|
||||
|
||||
#### Scenario: No color output
|
||||
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
|
||||
- **THEN** output uses text-only indicators without ANSI colors
|
||||
|
||||
#### Scenario: Progress indication
|
||||
- **WHEN** loading change state takes time
|
||||
- **THEN** the system displays a spinner during loading
|
||||
|
||||
### Requirement: Experimental Isolation
|
||||
The system SHALL implement artifact workflow commands in isolation for easy removal.
|
||||
|
||||
#### Scenario: Single file implementation
|
||||
- **WHEN** artifact workflow feature is implemented
|
||||
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
|
||||
|
||||
#### Scenario: Help text marking
|
||||
- **WHEN** user runs `--help` on any artifact workflow command
|
||||
- **THEN** help text indicates the command is experimental
|
||||
@@ -0,0 +1,48 @@
|
||||
## 1. Core Command Implementation
|
||||
|
||||
- [x] 1.1 Create `src/commands/artifact-workflow.ts` with all commands
|
||||
- [x] 1.2 Implement `status` command with text output
|
||||
- [x] 1.3 Implement `next` command with text output
|
||||
- [x] 1.4 Implement `instructions` command with text output
|
||||
- [x] 1.5 Implement `templates` command with text output
|
||||
- [x] 1.6 Implement `new change` subcommand using createChange()
|
||||
|
||||
## 2. CLI Registration
|
||||
|
||||
- [x] 2.1 Register `status` command in `src/cli/index.ts`
|
||||
- [x] 2.2 Register `next` command in `src/cli/index.ts`
|
||||
- [x] 2.3 Register `instructions` command in `src/cli/index.ts`
|
||||
- [x] 2.4 Register `templates` command in `src/cli/index.ts`
|
||||
- [x] 2.5 Register `new` command group with `change` subcommand
|
||||
|
||||
## 3. Output Formatting
|
||||
|
||||
- [x] 3.1 Add `--json` flag support to all commands
|
||||
- [x] 3.2 Add color-coded status indicators (done/ready/blocked)
|
||||
- [x] 3.3 Add progress spinner for loading operations
|
||||
- [x] 3.4 Support `--no-color` flag
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
- [x] 4.1 Handle missing `--change` parameter with helpful error
|
||||
- [x] 4.2 Handle unknown change names with list of available changes
|
||||
- [x] 4.3 Handle unknown artifact names with valid options
|
||||
- [x] 4.4 Handle schema resolution errors
|
||||
|
||||
## 5. Options and Flags
|
||||
|
||||
- [x] 5.1 Add `--schema` option for custom schema selection
|
||||
- [x] 5.2 Add `--description` option to `new change` command
|
||||
- [x] 5.3 Ensure options follow existing CLI patterns
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- [x] 6.1 Add smoke tests for each command
|
||||
- [x] 6.2 Test error cases (missing change, unknown artifact)
|
||||
- [x] 6.3 Test JSON output format
|
||||
- [x] 6.4 Test with different schemas
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [x] 7.1 Add help text for all commands marked as "Experimental"
|
||||
- [ ] 7.2 Update AGENTS.md with new commands (post-archive)
|
||||
@@ -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`
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# instruction-loader Specification
|
||||
|
||||
## Purpose
|
||||
Load templates from schema directories and enrich them with change-specific context for guiding artifact creation.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Template Loading
|
||||
The system SHALL load templates from schema directories.
|
||||
|
||||
#### Scenario: Load template from schema directory
|
||||
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
|
||||
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
|
||||
|
||||
#### Scenario: Template file not found
|
||||
- **WHEN** a template file does not exist in the schema's templates directory
|
||||
- **THEN** the system throws an error with the template path
|
||||
|
||||
### Requirement: Change Context Loading
|
||||
The system SHALL load change context combining graph and completion state.
|
||||
|
||||
#### Scenario: Load context for existing change
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change
|
||||
- **THEN** the system returns a context with graph, completed set, schema name, and change info
|
||||
|
||||
#### Scenario: Load context with custom schema
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called
|
||||
- **THEN** the system uses the specified schema instead of default
|
||||
|
||||
#### Scenario: Load context for non-existent change directory
|
||||
- **WHEN** `loadChangeContext` is called for a non-existent change directory
|
||||
- **THEN** the system returns context with empty completed set
|
||||
|
||||
### Requirement: Template Enrichment
|
||||
The system SHALL enrich templates with change-specific context.
|
||||
|
||||
#### Scenario: Include artifact metadata
|
||||
- **WHEN** instructions are generated for an artifact
|
||||
- **THEN** the output includes change name, artifact ID, schema name, and output path
|
||||
|
||||
#### Scenario: Include dependency status
|
||||
- **WHEN** an artifact has dependencies
|
||||
- **THEN** the output shows each dependency with completion status (done/missing)
|
||||
|
||||
#### Scenario: Include unlocked artifacts
|
||||
- **WHEN** instructions are generated
|
||||
- **THEN** the output includes which artifacts become available after this one
|
||||
|
||||
#### Scenario: Root artifact indicator
|
||||
- **WHEN** an artifact has no dependencies
|
||||
- **THEN** the dependency section indicates this is a root artifact
|
||||
|
||||
### Requirement: Status Formatting
|
||||
The system SHALL format change status as readable output.
|
||||
|
||||
#### Scenario: All artifacts completed
|
||||
- **WHEN** all artifacts are completed
|
||||
- **THEN** status shows all artifacts as "done"
|
||||
|
||||
#### Scenario: Mixed completion status
|
||||
- **WHEN** some artifacts are completed
|
||||
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
|
||||
|
||||
#### Scenario: Blocked artifact details
|
||||
- **WHEN** an artifact is blocked
|
||||
- **THEN** status shows which dependencies are missing
|
||||
|
||||
#### Scenario: Include output paths
|
||||
- **WHEN** status is formatted
|
||||
- **THEN** each artifact shows its output path pattern
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tasks
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
- [x] Create `instruction-loader` spec in `openspec/specs/instruction-loader/spec.md`
|
||||
- [x] Implement `loadTemplate` function to load templates from schema directories
|
||||
- [x] Implement `loadChangeContext` function to combine graph and completion state
|
||||
- [x] Implement `generateInstructions` function to enrich templates with change context
|
||||
- [x] Implement `formatChangeStatus` function for readable status output
|
||||
- [x] Export new functions from `src/core/artifact-graph/index.ts`
|
||||
- [x] Add comprehensive tests in `test/core/artifact-graph/instruction-loader.test.ts`
|
||||
- [x] Verify build passes
|
||||
- [x] Verify all tests pass
|
||||
@@ -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`)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Schema Loading
|
||||
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
|
||||
|
||||
#### Scenario: Valid schema loaded
|
||||
- **WHEN** a schema directory contains a valid `schema.yaml` file
|
||||
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
|
||||
|
||||
#### Scenario: Invalid schema rejected
|
||||
- **WHEN** a schema YAML file is missing required fields
|
||||
- **THEN** the system throws an error with a descriptive message
|
||||
|
||||
#### Scenario: Cyclic dependencies detected
|
||||
- **WHEN** a schema contains cyclic artifact dependencies
|
||||
- **THEN** the system throws an error listing the artifact IDs in the cycle
|
||||
|
||||
#### Scenario: Invalid dependency reference
|
||||
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
|
||||
- **THEN** the system throws an error identifying the invalid reference
|
||||
|
||||
#### Scenario: Duplicate artifact IDs rejected
|
||||
- **WHEN** a schema contains multiple artifacts with the same ID
|
||||
- **THEN** the system throws an error identifying the duplicate
|
||||
|
||||
#### Scenario: Schema directory not found
|
||||
- **WHEN** resolving a schema name that has no corresponding directory
|
||||
- **THEN** the system throws an error listing available schemas
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Schema Directory Structure
|
||||
The system SHALL support self-contained schema directories with co-located templates.
|
||||
|
||||
#### Scenario: Schema with templates
|
||||
- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory
|
||||
- **THEN** artifacts can reference templates relative to the schema's templates directory
|
||||
|
||||
#### Scenario: User schema override
|
||||
- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`
|
||||
- **THEN** the system uses that directory instead of the built-in
|
||||
|
||||
#### Scenario: Built-in schema fallback
|
||||
- **WHEN** no user override exists for a schema
|
||||
- **THEN** the system uses the package built-in schema directory
|
||||
|
||||
#### Scenario: List available schemas
|
||||
- **WHEN** listing schemas
|
||||
- **THEN** the system returns schema names from both user and package directories
|
||||
@@ -0,0 +1,32 @@
|
||||
## 1. Create Schema Directories
|
||||
|
||||
- [ ] 1.1 Create `schemas/` directory at package root
|
||||
- [ ] 1.2 Create `schemas/spec-driven/schema.yaml` from `SPEC_DRIVEN_SCHEMA`
|
||||
- [ ] 1.3 Create `schemas/spec-driven/templates/` with placeholder templates
|
||||
- [ ] 1.4 Create `schemas/tdd/schema.yaml` from `TDD_SCHEMA`
|
||||
- [ ] 1.5 Create `schemas/tdd/templates/` with placeholder templates
|
||||
|
||||
## 2. Update Schema Resolution
|
||||
|
||||
- [ ] 2.1 Add `getPackageSchemasDir()` function using `import.meta.url`
|
||||
- [ ] 2.2 Add `getSchemaDir(name)` to resolve schema directory path
|
||||
- [ ] 2.3 Update `resolveSchema()` to load from directory structure
|
||||
- [ ] 2.4 Update `listSchemas()` to scan directories instead of object keys
|
||||
- [ ] 2.5 Add tests for user override resolution
|
||||
- [ ] 2.6 Add tests for built-in fallback
|
||||
|
||||
## 3. Cleanup
|
||||
|
||||
- [ ] 3.1 Remove `builtin-schemas.ts`
|
||||
- [ ] 3.2 Update `index.ts` exports (remove `BUILTIN_SCHEMAS`, `SPEC_DRIVEN_SCHEMA`, `TDD_SCHEMA`)
|
||||
- [ ] 3.3 Update any code that imports removed exports
|
||||
|
||||
## 4. Package Distribution
|
||||
|
||||
- [ ] 4.1 Add `schemas/` to `files` array in `package.json`
|
||||
- [ ] 4.2 Verify schemas are included in built package
|
||||
|
||||
## 5. Fix Template Paths
|
||||
|
||||
- [ ] 5.1 Update `template` field in schema.yaml files (remove `templates/` prefix)
|
||||
- [ ] 5.2 Ensure template paths are relative to schema's templates directory
|
||||
@@ -0,0 +1,151 @@
|
||||
# Design: Unify Change State Model
|
||||
|
||||
## Overview
|
||||
|
||||
This change fixes two bugs with minimal disruption to the existing system:
|
||||
|
||||
1. **View bug**: Empty changes incorrectly shown as "Completed"
|
||||
2. **Artifact workflow bug**: Commands fail on scaffolded changes
|
||||
|
||||
## Key Design Decision: Two Systems, Two Purposes
|
||||
|
||||
The task-based and artifact-based systems serve **different purposes** and should coexist:
|
||||
|
||||
| System | Purpose | Used By |
|
||||
|--------|---------|---------|
|
||||
| **Task Progress** | Track implementation work | `openspec view`, `openspec list` |
|
||||
| **Artifact Progress** | Track planning/spec work | `openspec status`, `openspec next` |
|
||||
|
||||
We do NOT merge these systems. Instead, we fix each to work correctly in its domain.
|
||||
|
||||
## Change 1: Fix View Command
|
||||
|
||||
### Current Logic (Buggy)
|
||||
|
||||
```typescript
|
||||
// view.ts line 90
|
||||
if (progress.total === 0 || progress.completed === progress.total) {
|
||||
completed.push({ name: entry.name });
|
||||
}
|
||||
```
|
||||
|
||||
Problem: `total === 0` means "no tasks defined yet", not "all tasks done".
|
||||
|
||||
### New Logic
|
||||
|
||||
```typescript
|
||||
if (progress.total === 0) {
|
||||
draft.push({ name: entry.name });
|
||||
} else if (progress.completed === progress.total) {
|
||||
completed.push({ name: entry.name });
|
||||
} else {
|
||||
active.push({ name: entry.name, progress });
|
||||
}
|
||||
```
|
||||
|
||||
### View Output Change
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Completed Changes
|
||||
─────────────────
|
||||
✓ add-feature (all tasks done - correct)
|
||||
✓ test-workflow (no tasks - WRONG)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Draft Changes
|
||||
─────────────────
|
||||
○ test-workflow (no tasks yet)
|
||||
|
||||
Active Changes
|
||||
─────────────────
|
||||
◉ add-scaffold [████░░░░] 3/7 tasks
|
||||
|
||||
Completed Changes
|
||||
─────────────────
|
||||
✓ add-feature (all tasks done)
|
||||
```
|
||||
|
||||
## Change 2: Fix Artifact Workflow Discovery
|
||||
|
||||
### Current Logic (Buggy)
|
||||
|
||||
```typescript
|
||||
// artifact-workflow.ts - validateChangeExists()
|
||||
const activeChanges = await getActiveChangeIds(projectRoot);
|
||||
if (!activeChanges.includes(changeName)) {
|
||||
throw new Error(`Change '${changeName}' not found...`);
|
||||
}
|
||||
```
|
||||
|
||||
Problem: `getActiveChangeIds()` requires `proposal.md`, but artifact workflow should work on empty directories to help create the first artifact.
|
||||
|
||||
### New Logic
|
||||
|
||||
```typescript
|
||||
async function validateChangeExists(changeName: string, projectRoot: string): Promise<string> {
|
||||
const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
|
||||
|
||||
// Check directory existence directly, not proposal.md
|
||||
if (!fs.existsSync(changePath) || !fs.statSync(changePath).isDirectory()) {
|
||||
// List available changes for helpful error message
|
||||
const entries = await fs.promises.readdir(
|
||||
path.join(projectRoot, 'openspec', 'changes'),
|
||||
{ withFileTypes: true }
|
||||
);
|
||||
const available = entries
|
||||
.filter(e => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
|
||||
.map(e => e.name);
|
||||
|
||||
if (available.length === 0) {
|
||||
throw new Error('No changes found. Create one with: openspec new change <name>');
|
||||
}
|
||||
throw new Error(`Change '${changeName}' not found. Available:\n ${available.join('\n ')}`);
|
||||
}
|
||||
|
||||
return changeName;
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior Change
|
||||
|
||||
```bash
|
||||
# Before
|
||||
$ openspec new change foo
|
||||
$ openspec status --change foo
|
||||
Error: Change 'foo' not found.
|
||||
|
||||
# After
|
||||
$ openspec new change foo
|
||||
$ openspec status --change foo
|
||||
Change: foo
|
||||
Progress: 0/4 artifacts complete
|
||||
|
||||
[ ] proposal
|
||||
[-] specs (blocked by: proposal)
|
||||
[-] design (blocked by: proposal)
|
||||
[-] tasks (blocked by: specs, design)
|
||||
```
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
1. **`getActiveChangeIds()`** - Still requires `proposal.md` (used by validate, show)
|
||||
2. **`getArchivedChangeIds()`** - Unchanged
|
||||
3. **Active/Completed semantics** - Still based on task checkboxes
|
||||
4. **Validation** - Still requires `proposal.md` to have something to validate
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/core/view.ts` | Add draft category, fix completion logic |
|
||||
| `src/commands/artifact-workflow.ts` | Update `validateChangeExists()` to use directory existence |
|
||||
| `test/commands/artifact-workflow.test.ts` | Add tests for scaffolded changes |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit test**: `validateChangeExists()` with scaffolded change
|
||||
2. **View test**: Verify three categories render correctly
|
||||
3. **Manual test**: Full workflow from `new change` → `status` → `view`
|
||||
@@ -0,0 +1,101 @@
|
||||
# Proposal: Unify Change State Model
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Two bugs create inconsistent behavior when working with changes:
|
||||
|
||||
### Bug 1: Empty changes shown as "Completed" in view
|
||||
|
||||
```typescript
|
||||
// view.ts line 90
|
||||
if (progress.total === 0 || progress.completed === progress.total) {
|
||||
completed.push({ name: entry.name }); // BUG: total === 0 ≠ completed
|
||||
}
|
||||
```
|
||||
|
||||
Result: `openspec new change foo && openspec view` shows `foo` as "Completed" when it has no content.
|
||||
|
||||
### Bug 2: Artifact workflow commands can't find scaffolded changes
|
||||
|
||||
```typescript
|
||||
// item-discovery.ts - getActiveChangeIds()
|
||||
const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
|
||||
await fs.access(proposalPath); // Only returns changes WITH proposal.md
|
||||
```
|
||||
|
||||
Result: `openspec status --change foo` says "not found" even though the directory exists.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The system conflates two different concepts:
|
||||
|
||||
| Concept | Question | Source of Truth |
|
||||
|---------|----------|-----------------|
|
||||
| **Planning Progress** | Are all spec documents created? | File existence (ArtifactGraph) |
|
||||
| **Implementation Progress** | Is the coding work done? | Task checkboxes (tasks.md) |
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Fix 1: Add "Draft" state to view command
|
||||
|
||||
Keep Active/Completed with their existing meanings, but fix the bug:
|
||||
|
||||
| State | Criteria | Meaning |
|
||||
|-------|----------|---------|
|
||||
| **Draft** | No tasks.md OR `tasks.total === 0` | Still planning |
|
||||
| **Active** | `tasks.total > 0` AND `completed < total` | Implementing |
|
||||
| **Completed** | `tasks.total > 0` AND `completed === total` | Done |
|
||||
|
||||
### Fix 2: Artifact workflow uses directory existence
|
||||
|
||||
Update `validateChangeExists()` to check if the directory exists, not if `proposal.md` exists. This allows the artifact workflow to guide users through creating their first artifact.
|
||||
|
||||
### Keep existing discovery functions
|
||||
|
||||
`getActiveChangeIds()` continues to require `proposal.md` for backward compatibility with validation and other commands.
|
||||
|
||||
## What Changes
|
||||
|
||||
| Command | Before | After |
|
||||
|---------|--------|-------|
|
||||
| `openspec view` | Empty = "Completed" | Empty = "Draft" |
|
||||
| `openspec status --change X` | Requires proposal.md | Works on any directory |
|
||||
| `openspec validate X` | Requires proposal.md | Unchanged (still requires it) |
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### Minimal Breaking Change
|
||||
|
||||
1. **`openspec view` output**: Empty changes move from "Completed" section to new "Draft" section
|
||||
|
||||
### Non-Breaking
|
||||
|
||||
- Active/Completed semantics unchanged (still task-based)
|
||||
- `getActiveChangeIds()` unchanged
|
||||
- `openspec validate` unchanged
|
||||
- Archived changes unaffected
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Merging task-based and artifact-based progress (they serve different purposes)
|
||||
- Changing what "Completed" means (it stays = all tasks done)
|
||||
- Adding artifact progress to view command (separate enhancement)
|
||||
- Shell tab completions for artifact workflow commands (not yet registered)
|
||||
|
||||
## Related Commands Analysis
|
||||
|
||||
| Command | Uses `getActiveChangeIds()` | Should include scaffolded? | Change needed? |
|
||||
|---------|-----------------------------|-----------------------------|----------------|
|
||||
| `openspec view` | No (reads dirs directly) | Yes → Draft section | **Yes** |
|
||||
| `openspec list` | No (reads dirs directly) | Yes (shows "No tasks") | No |
|
||||
| `openspec status/next/instructions` | Yes | Yes | **Yes** |
|
||||
| `openspec validate` | Yes | No (can't validate empty) | No |
|
||||
| `openspec show` | Yes | No (nothing to show) | No |
|
||||
| Tab completions | Yes | Future enhancement | No |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. `openspec new change foo && openspec view` shows `foo` in "Draft" section
|
||||
2. `openspec new change foo && openspec status --change foo` works
|
||||
3. Changes with all tasks done still show as "Completed"
|
||||
4. All existing tests pass
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# cli-artifact-workflow Specification Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Status Command
|
||||
|
||||
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
|
||||
|
||||
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
|
||||
|
||||
#### Scenario: Show status with all states
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** the system displays each artifact with status indicator:
|
||||
- `[x]` for completed artifacts
|
||||
- `[ ]` for ready artifacts
|
||||
- `[-]` for blocked artifacts (with missing dependencies listed)
|
||||
|
||||
#### Scenario: Status shows completion summary
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
|
||||
|
||||
#### Scenario: Status JSON output
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id> --json`
|
||||
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
|
||||
|
||||
#### Scenario: Status on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
|
||||
- **THEN** system displays all artifacts with their status
|
||||
- **AND** root artifacts (no dependencies) show as ready `[ ]`
|
||||
- **AND** dependent artifacts show as blocked `[-]`
|
||||
|
||||
#### Scenario: Missing change parameter
|
||||
|
||||
- **WHEN** user runs `openspec status` without `--change`
|
||||
- **THEN** the system displays an error with list of available changes
|
||||
- **AND** includes scaffolded changes (directories without proposal.md)
|
||||
|
||||
#### Scenario: Unknown change
|
||||
|
||||
- **WHEN** user runs `openspec status --change unknown-id`
|
||||
- **AND** directory `openspec/changes/unknown-id/` does not exist
|
||||
- **THEN** the system displays an error listing all available change directories
|
||||
|
||||
### Requirement: Next Command
|
||||
|
||||
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show ready artifacts
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>`
|
||||
- **THEN** the system lists artifacts whose dependencies are all satisfied
|
||||
|
||||
#### Scenario: No artifacts ready
|
||||
|
||||
- **WHEN** all artifacts are either completed or blocked
|
||||
- **THEN** the system indicates no artifacts are ready (with explanation)
|
||||
|
||||
#### Scenario: All artifacts complete
|
||||
|
||||
- **WHEN** all artifacts in the change are completed
|
||||
- **THEN** the system indicates the change is complete
|
||||
|
||||
#### Scenario: Next JSON output
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id> --json`
|
||||
- **THEN** the system outputs JSON array of ready artifact IDs
|
||||
|
||||
#### Scenario: Next on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
|
||||
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
|
||||
|
||||
### Requirement: Instructions Command
|
||||
|
||||
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show enriched instructions
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
|
||||
- **THEN** the system outputs:
|
||||
- Artifact metadata (ID, output path, description)
|
||||
- Template content
|
||||
- Dependency status (done/missing)
|
||||
- Unlocked artifacts (what becomes available after completion)
|
||||
|
||||
#### Scenario: Instructions JSON output
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
|
||||
- **THEN** the system outputs JSON matching ArtifactInstructions interface
|
||||
|
||||
#### Scenario: Unknown artifact
|
||||
|
||||
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
|
||||
- **THEN** the system displays an error listing valid artifact IDs for the schema
|
||||
|
||||
#### Scenario: Artifact with unmet dependencies
|
||||
|
||||
- **WHEN** user requests instructions for a blocked artifact
|
||||
- **THEN** the system displays instructions with a warning about missing dependencies
|
||||
|
||||
#### Scenario: Instructions on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
|
||||
- **THEN** system outputs template and metadata for creating the proposal
|
||||
- **AND** does not require any artifacts to already exist
|
||||
@@ -0,0 +1,60 @@
|
||||
# cli-view Specification Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Draft Changes Display
|
||||
|
||||
The dashboard SHALL display changes without tasks in a separate "Draft" section.
|
||||
|
||||
#### Scenario: Draft changes listing
|
||||
|
||||
- **WHEN** there are changes with no tasks.md or zero tasks defined
|
||||
- **THEN** system shows them in a "Draft Changes" section
|
||||
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
|
||||
|
||||
#### Scenario: Draft section ordering
|
||||
|
||||
- **WHEN** multiple draft changes exist
|
||||
- **THEN** system sorts them alphabetically by name
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Completed Changes Display
|
||||
|
||||
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
|
||||
|
||||
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
|
||||
|
||||
#### Scenario: Completed changes listing
|
||||
|
||||
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
|
||||
- **THEN** system shows them with checkmark indicators in a dedicated section
|
||||
|
||||
#### Scenario: Mixed completion states
|
||||
|
||||
- **WHEN** some changes are complete and others active
|
||||
- **THEN** system separates them into appropriate sections
|
||||
|
||||
#### Scenario: Empty changes not completed
|
||||
|
||||
- **WHEN** a change has no tasks.md or zero tasks defined
|
||||
- **THEN** system does NOT show it in "Completed Changes" section
|
||||
- **AND** shows it in "Draft Changes" section instead
|
||||
|
||||
### Requirement: Summary Section
|
||||
|
||||
The dashboard SHALL display a summary section with key project metrics, including draft change count.
|
||||
|
||||
#### Scenario: Complete summary display
|
||||
|
||||
- **WHEN** dashboard is rendered with specs and changes
|
||||
- **THEN** system shows total number of specifications and requirements
|
||||
- **AND** shows number of draft changes
|
||||
- **AND** shows number of active changes in progress
|
||||
- **AND** shows number of completed changes
|
||||
- **AND** shows overall task progress percentage
|
||||
|
||||
#### Scenario: Empty project summary
|
||||
|
||||
- **WHEN** no specs or changes exist
|
||||
- **THEN** summary shows zero counts for all metrics
|
||||
@@ -0,0 +1,25 @@
|
||||
# Tasks: Unify Change State Model
|
||||
|
||||
## Phase 1: Fix Artifact Workflow Discovery
|
||||
|
||||
- [x] Update `validateChangeExists()` in `artifact-workflow.ts` to check directory existence instead of using `getActiveChangeIds()`
|
||||
- [x] Update error message to list all change directories (not just those with proposal.md)
|
||||
- [x] Add test for `openspec status --change <scaffolded-change>`
|
||||
- [x] Add test for `openspec next --change <scaffolded-change>`
|
||||
- [x] Add test for `openspec instructions proposal --change <scaffolded-change>`
|
||||
|
||||
## Phase 2: Fix View Command
|
||||
|
||||
- [x] Update `getChangesData()` in `view.ts` to return three categories: draft, active, completed
|
||||
- [x] Fix completion logic: `total === 0` → draft, not completed
|
||||
- [x] Add "Draft Changes" section to dashboard rendering
|
||||
- [x] Update summary to include draft count
|
||||
- [x] Add test for draft changes appearing correctly in view
|
||||
|
||||
## Phase 3: Cleanup and Validation
|
||||
|
||||
- [x] Clean up test changes (`test-workflow`, `test-workflow-2`)
|
||||
- [x] Run full test suite
|
||||
- [x] Manual test: `openspec new change foo && openspec status --change foo`
|
||||
- [x] Manual test: `openspec new change foo && openspec view` shows foo in Draft
|
||||
- [x] Validate with `openspec validate unify-change-state-model --strict`
|
||||
@@ -0,0 +1,130 @@
|
||||
# artifact-graph Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-artifact-graph-core. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Schema Loading
|
||||
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
|
||||
|
||||
#### Scenario: Valid schema loaded
|
||||
- **WHEN** a schema directory contains a valid `schema.yaml` file
|
||||
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
|
||||
|
||||
#### Scenario: Invalid schema rejected
|
||||
- **WHEN** a schema YAML file is missing required fields
|
||||
- **THEN** the system throws an error with a descriptive message
|
||||
|
||||
#### Scenario: Cyclic dependencies detected
|
||||
- **WHEN** a schema contains cyclic artifact dependencies
|
||||
- **THEN** the system throws an error listing the artifact IDs in the cycle
|
||||
|
||||
#### Scenario: Invalid dependency reference
|
||||
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
|
||||
- **THEN** the system throws an error identifying the invalid reference
|
||||
|
||||
#### Scenario: Duplicate artifact IDs rejected
|
||||
- **WHEN** a schema contains multiple artifacts with the same ID
|
||||
- **THEN** the system throws an error identifying the duplicate
|
||||
|
||||
#### Scenario: Schema directory not found
|
||||
- **WHEN** resolving a schema name that has no corresponding directory
|
||||
- **THEN** the system throws an error listing available schemas
|
||||
|
||||
### Requirement: Build Order Calculation
|
||||
The system SHALL compute a valid topological build order for artifacts.
|
||||
|
||||
#### Scenario: Linear dependency chain
|
||||
- **WHEN** artifacts form a linear chain (A → B → C)
|
||||
- **THEN** getBuildOrder() returns [A, B, C]
|
||||
|
||||
#### Scenario: Diamond dependency
|
||||
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
|
||||
- **THEN** getBuildOrder() returns A before B and C, and D last
|
||||
|
||||
#### Scenario: Independent artifacts
|
||||
- **WHEN** artifacts have no dependencies
|
||||
- **THEN** getBuildOrder() returns them in a stable order
|
||||
|
||||
### Requirement: State Detection
|
||||
The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
|
||||
#### Scenario: Simple file exists
|
||||
- **WHEN** an artifact generates "proposal.md" and the file exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Simple file missing
|
||||
- **WHEN** an artifact generates "proposal.md" and the file does not exist
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Glob pattern with files
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Glob pattern empty
|
||||
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Missing change directory
|
||||
- **WHEN** the change directory does not exist
|
||||
- **THEN** all artifacts are marked as not completed (empty state)
|
||||
|
||||
### Requirement: Ready Artifact Query
|
||||
The system SHALL identify which artifacts are ready to be created based on dependency completion.
|
||||
|
||||
#### Scenario: Root artifacts ready initially
|
||||
- **WHEN** no artifacts are completed
|
||||
- **THEN** getNextArtifacts() returns artifacts with no dependencies
|
||||
|
||||
#### Scenario: Dependent artifact becomes ready
|
||||
- **WHEN** an artifact's dependencies are all completed
|
||||
- **THEN** getNextArtifacts() includes that artifact
|
||||
|
||||
#### Scenario: Blocked artifacts excluded
|
||||
- **WHEN** an artifact has uncompleted dependencies
|
||||
- **THEN** getNextArtifacts() does not include that artifact
|
||||
|
||||
### Requirement: Completion Check
|
||||
The system SHALL determine when all artifacts in a graph are complete.
|
||||
|
||||
#### Scenario: All complete
|
||||
- **WHEN** all artifacts in the graph are in the completed set
|
||||
- **THEN** isComplete() returns true
|
||||
|
||||
#### Scenario: Partially complete
|
||||
- **WHEN** some artifacts in the graph are not completed
|
||||
- **THEN** isComplete() returns false
|
||||
|
||||
### Requirement: Blocked Query
|
||||
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
|
||||
|
||||
#### Scenario: Artifact blocked by single dependency
|
||||
- **WHEN** artifact B requires artifact A and A is not complete
|
||||
- **THEN** getBlocked() returns `{ B: ['A'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by multiple dependencies
|
||||
- **WHEN** artifact C requires A and B, and only A is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['B'] }`
|
||||
|
||||
#### Scenario: Artifact blocked by all dependencies
|
||||
- **WHEN** artifact C requires A and B, and neither is complete
|
||||
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
|
||||
|
||||
### Requirement: Schema Directory Structure
|
||||
The system SHALL support self-contained schema directories with co-located templates.
|
||||
|
||||
#### Scenario: Schema with templates
|
||||
- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory
|
||||
- **THEN** artifacts can reference templates relative to the schema's templates directory
|
||||
|
||||
#### Scenario: User schema override
|
||||
- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`
|
||||
- **THEN** the system uses that directory instead of the built-in
|
||||
|
||||
#### Scenario: Built-in schema fallback
|
||||
- **WHEN** no user override exists for a schema
|
||||
- **THEN** the system uses the package built-in schema directory
|
||||
|
||||
#### Scenario: List available schemas
|
||||
- **WHEN** listing schemas
|
||||
- **THEN** the system returns schema names from both user and package directories
|
||||
|
||||
@@ -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: "..." }`
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# cli-artifact-workflow Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-artifact-workflow-cli. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Status Command
|
||||
|
||||
The system SHALL display artifact completion status for a change, including scaffolded (empty) changes.
|
||||
|
||||
> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.
|
||||
|
||||
#### Scenario: Show status with all states
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** the system displays each artifact with status indicator:
|
||||
- `[x]` for completed artifacts
|
||||
- `[ ]` for ready artifacts
|
||||
- `[-]` for blocked artifacts (with missing dependencies listed)
|
||||
|
||||
#### Scenario: Status shows completion summary
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>`
|
||||
- **THEN** output includes completion percentage and count (e.g., "2/4 artifacts complete")
|
||||
|
||||
#### Scenario: Status JSON output
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id> --json`
|
||||
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
|
||||
|
||||
#### Scenario: Status on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
|
||||
- **THEN** system displays all artifacts with their status
|
||||
- **AND** root artifacts (no dependencies) show as ready `[ ]`
|
||||
- **AND** dependent artifacts show as blocked `[-]`
|
||||
|
||||
#### Scenario: Missing change parameter
|
||||
|
||||
- **WHEN** user runs `openspec status` without `--change`
|
||||
- **THEN** the system displays an error with list of available changes
|
||||
- **AND** includes scaffolded changes (directories without proposal.md)
|
||||
|
||||
#### Scenario: Unknown change
|
||||
|
||||
- **WHEN** user runs `openspec status --change unknown-id`
|
||||
- **AND** directory `openspec/changes/unknown-id/` does not exist
|
||||
- **THEN** the system displays an error listing all available change directories
|
||||
|
||||
### Requirement: Next Command
|
||||
|
||||
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show ready artifacts
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>`
|
||||
- **THEN** the system lists artifacts whose dependencies are all satisfied
|
||||
|
||||
#### Scenario: No artifacts ready
|
||||
|
||||
- **WHEN** all artifacts are either completed or blocked
|
||||
- **THEN** the system indicates no artifacts are ready (with explanation)
|
||||
|
||||
#### Scenario: All artifacts complete
|
||||
|
||||
- **WHEN** all artifacts in the change are completed
|
||||
- **THEN** the system indicates the change is complete
|
||||
|
||||
#### Scenario: Next JSON output
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id> --json`
|
||||
- **THEN** the system outputs JSON array of ready artifact IDs
|
||||
|
||||
#### Scenario: Next on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
|
||||
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
|
||||
|
||||
### Requirement: Instructions Command
|
||||
|
||||
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
|
||||
|
||||
#### Scenario: Show enriched instructions
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id>`
|
||||
- **THEN** the system outputs:
|
||||
- Artifact metadata (ID, output path, description)
|
||||
- Template content
|
||||
- Dependency status (done/missing)
|
||||
- Unlocked artifacts (what becomes available after completion)
|
||||
|
||||
#### Scenario: Instructions JSON output
|
||||
|
||||
- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`
|
||||
- **THEN** the system outputs JSON matching ArtifactInstructions interface
|
||||
|
||||
#### Scenario: Unknown artifact
|
||||
|
||||
- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`
|
||||
- **THEN** the system displays an error listing valid artifact IDs for the schema
|
||||
|
||||
#### Scenario: Artifact with unmet dependencies
|
||||
|
||||
- **WHEN** user requests instructions for a blocked artifact
|
||||
- **THEN** the system displays instructions with a warning about missing dependencies
|
||||
|
||||
#### Scenario: Instructions on scaffolded change
|
||||
|
||||
- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change
|
||||
- **THEN** system outputs template and metadata for creating the proposal
|
||||
- **AND** does not require any artifacts to already exist
|
||||
|
||||
### Requirement: Templates Command
|
||||
The system SHALL show resolved template paths for all artifacts in a schema.
|
||||
|
||||
#### Scenario: List template paths with default schema
|
||||
- **WHEN** user runs `openspec templates`
|
||||
- **THEN** the system displays each artifact with its resolved template path using the default schema
|
||||
|
||||
#### Scenario: List template paths with custom schema
|
||||
- **WHEN** user runs `openspec templates --schema tdd`
|
||||
- **THEN** the system displays template paths for the specified schema
|
||||
|
||||
#### Scenario: Templates JSON output
|
||||
- **WHEN** user runs `openspec templates --json`
|
||||
- **THEN** the system outputs JSON mapping artifact IDs to template paths
|
||||
|
||||
#### Scenario: Template resolution source
|
||||
- **WHEN** displaying template paths
|
||||
- **THEN** the system indicates whether each template is from user override or package built-in
|
||||
|
||||
### Requirement: New Change Command
|
||||
The system SHALL create new change directories with validation.
|
||||
|
||||
#### Scenario: Create valid change
|
||||
- **WHEN** user runs `openspec new change add-feature`
|
||||
- **THEN** the system creates `openspec/changes/add-feature/` directory
|
||||
|
||||
#### Scenario: Invalid change name
|
||||
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
|
||||
- **THEN** the system displays validation error with guidance
|
||||
|
||||
#### Scenario: Duplicate change name
|
||||
- **WHEN** user runs `openspec new change existing-change` for an existing change
|
||||
- **THEN** the system displays an error indicating the change already exists
|
||||
|
||||
#### Scenario: Create with description
|
||||
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
|
||||
- **THEN** the system creates the change directory with description in README.md
|
||||
|
||||
### Requirement: Schema Selection
|
||||
The system SHALL support custom schema selection for workflow commands.
|
||||
|
||||
#### Scenario: Default schema
|
||||
- **WHEN** user runs workflow commands without `--schema`
|
||||
- **THEN** the system uses the "spec-driven" schema
|
||||
|
||||
#### Scenario: Custom schema
|
||||
- **WHEN** user runs `openspec status --change <id> --schema tdd`
|
||||
- **THEN** the system uses the specified schema for artifact graph
|
||||
|
||||
#### Scenario: Unknown schema
|
||||
- **WHEN** user specifies an unknown schema
|
||||
- **THEN** the system displays an error listing available schemas
|
||||
|
||||
### Requirement: Output Formatting
|
||||
The system SHALL provide consistent output formatting.
|
||||
|
||||
#### Scenario: Color output
|
||||
- **WHEN** terminal supports colors
|
||||
- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)
|
||||
|
||||
#### Scenario: No color output
|
||||
- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set
|
||||
- **THEN** output uses text-only indicators without ANSI colors
|
||||
|
||||
#### Scenario: Progress indication
|
||||
- **WHEN** loading change state takes time
|
||||
- **THEN** the system displays a spinner during loading
|
||||
|
||||
### Requirement: Experimental Isolation
|
||||
The system SHALL implement artifact workflow commands in isolation for easy removal.
|
||||
|
||||
#### Scenario: Single file implementation
|
||||
- **WHEN** artifact workflow feature is implemented
|
||||
- **THEN** all commands are in `src/commands/artifact-workflow.ts`
|
||||
|
||||
#### Scenario: Help text marking
|
||||
- **WHEN** user runs `--help` on any artifact workflow command
|
||||
- **THEN** help text indicates the command is experimental
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# cli-config Specification
|
||||
|
||||
## Purpose
|
||||
Provide a user-friendly CLI interface for viewing and modifying global OpenSpec configuration settings without manually editing JSON files.
|
||||
## Requirements
|
||||
### Requirement: Command Structure
|
||||
|
||||
The config command SHALL provide subcommands for all configuration operations.
|
||||
|
||||
#### Scenario: Available subcommands
|
||||
|
||||
- **WHEN** user executes `openspec config --help`
|
||||
- **THEN** display available subcommands:
|
||||
- `path` - Show config file location
|
||||
- `list` - Show all current settings
|
||||
- `get <key>` - Get a specific value
|
||||
- `set <key> <value>` - Set a value
|
||||
- `unset <key>` - Remove a key (revert to default)
|
||||
- `reset` - Reset configuration to defaults
|
||||
- `edit` - Open config in editor
|
||||
|
||||
### Requirement: Config Path
|
||||
|
||||
The config command SHALL display the config file location.
|
||||
|
||||
#### Scenario: Show config path
|
||||
|
||||
- **WHEN** user executes `openspec config path`
|
||||
- **THEN** print the absolute path to the config file
|
||||
- **AND** exit with code 0
|
||||
|
||||
### Requirement: Config List
|
||||
|
||||
The config command SHALL display all current configuration values.
|
||||
|
||||
#### Scenario: List config in human-readable format
|
||||
|
||||
- **WHEN** user executes `openspec config list`
|
||||
- **THEN** display all config values in YAML-like format
|
||||
- **AND** show nested objects with indentation
|
||||
|
||||
#### Scenario: List config as JSON
|
||||
|
||||
- **WHEN** user executes `openspec config list --json`
|
||||
- **THEN** output the complete config as valid JSON
|
||||
- **AND** output only JSON (no additional text)
|
||||
|
||||
### Requirement: Config Get
|
||||
|
||||
The config command SHALL retrieve specific configuration values.
|
||||
|
||||
#### Scenario: Get top-level key
|
||||
|
||||
- **WHEN** user executes `openspec config get <key>` with a valid top-level key
|
||||
- **THEN** print the raw value only (no labels or formatting)
|
||||
- **AND** exit with code 0
|
||||
|
||||
#### Scenario: Get nested key with dot notation
|
||||
|
||||
- **WHEN** user executes `openspec config get featureFlags.someFlag`
|
||||
- **THEN** traverse the nested structure using dot notation
|
||||
- **AND** print the value at that path
|
||||
|
||||
#### Scenario: Get non-existent key
|
||||
|
||||
- **WHEN** user executes `openspec config get <key>` with a key that does not exist
|
||||
- **THEN** print nothing (empty output)
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Get object value
|
||||
|
||||
- **WHEN** user executes `openspec config get <key>` where the value is an object
|
||||
- **THEN** print the object as JSON
|
||||
|
||||
### Requirement: Config Set
|
||||
|
||||
The config command SHALL set configuration values with automatic type coercion.
|
||||
|
||||
#### Scenario: Set string value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value>`
|
||||
- **AND** value does not match boolean or number patterns
|
||||
- **THEN** store value as a string
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Set boolean value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> true` or `openspec config set <key> false`
|
||||
- **THEN** store value as boolean (not string)
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Set numeric value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value>`
|
||||
- **AND** value is a valid number (integer or float)
|
||||
- **THEN** store value as number (not string)
|
||||
|
||||
#### Scenario: Force string with --string flag
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value> --string`
|
||||
- **THEN** store value as string regardless of content
|
||||
- **AND** this allows storing literal "true" or "123" as strings
|
||||
|
||||
#### Scenario: Set nested key
|
||||
|
||||
- **WHEN** user executes `openspec config set featureFlags.newFlag true`
|
||||
- **THEN** create intermediate objects if they don't exist
|
||||
- **AND** set the value at the nested path
|
||||
|
||||
### Requirement: Config Unset
|
||||
|
||||
The config command SHALL remove configuration overrides.
|
||||
|
||||
#### Scenario: Unset existing key
|
||||
|
||||
- **WHEN** user executes `openspec config unset <key>`
|
||||
- **AND** the key exists in the config
|
||||
- **THEN** remove the key from the config file
|
||||
- **AND** the value reverts to its default
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Unset non-existent key
|
||||
|
||||
- **WHEN** user executes `openspec config unset <key>`
|
||||
- **AND** the key does not exist in the config
|
||||
- **THEN** display message indicating key was not set
|
||||
- **AND** exit with code 0
|
||||
|
||||
### Requirement: Config Reset
|
||||
|
||||
The config command SHALL reset configuration to defaults.
|
||||
|
||||
#### Scenario: Reset all with confirmation
|
||||
|
||||
- **WHEN** user executes `openspec config reset --all`
|
||||
- **THEN** prompt for confirmation before proceeding
|
||||
- **AND** if confirmed, delete the config file or reset to defaults
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Reset all with -y flag
|
||||
|
||||
- **WHEN** user executes `openspec config reset --all -y`
|
||||
- **THEN** reset without prompting for confirmation
|
||||
|
||||
#### Scenario: Reset without --all flag
|
||||
|
||||
- **WHEN** user executes `openspec config reset` without `--all`
|
||||
- **THEN** display error indicating `--all` is required
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Config Edit
|
||||
|
||||
The config command SHALL open the config file in the user's editor.
|
||||
|
||||
#### Scenario: Open editor successfully
|
||||
|
||||
- **WHEN** user executes `openspec config edit`
|
||||
- **AND** `$EDITOR` or `$VISUAL` environment variable is set
|
||||
- **THEN** open the config file in that editor
|
||||
- **AND** create the config file with defaults if it doesn't exist
|
||||
- **AND** wait for the editor to close before returning
|
||||
|
||||
#### Scenario: No editor configured
|
||||
|
||||
- **WHEN** user executes `openspec config edit`
|
||||
- **AND** neither `$EDITOR` nor `$VISUAL` is set
|
||||
- **THEN** display error message suggesting to set `$EDITOR`
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Key Naming Convention
|
||||
|
||||
The config command SHALL use camelCase keys matching the JSON structure.
|
||||
|
||||
#### Scenario: Keys match JSON structure
|
||||
|
||||
- **WHEN** accessing configuration keys via CLI
|
||||
- **THEN** use camelCase matching the actual JSON property names
|
||||
- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`)
|
||||
|
||||
### Requirement: Schema Validation
|
||||
|
||||
The config command SHALL validate configuration writes against the config schema using zod, while rejecting unknown keys for `config set` unless explicitly overridden.
|
||||
|
||||
#### Scenario: Unknown key rejected by default
|
||||
|
||||
- **WHEN** user executes `openspec config set someFutureKey 123`
|
||||
- **THEN** display a descriptive error message indicating the key is invalid
|
||||
- **AND** do not modify the config file
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Unknown key accepted with override
|
||||
|
||||
- **WHEN** user executes `openspec config set someFutureKey 123 --allow-unknown`
|
||||
- **THEN** the value is saved successfully
|
||||
- **AND** exit with code 0
|
||||
|
||||
#### Scenario: Invalid feature flag value rejected
|
||||
|
||||
- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean`
|
||||
- **THEN** display a descriptive error message
|
||||
- **AND** do not modify the config file
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Reserved Scope Flag
|
||||
|
||||
The config command SHALL reserve the `--scope` flag for future extensibility.
|
||||
|
||||
#### Scenario: Scope flag defaults to global
|
||||
|
||||
- **WHEN** user executes any config command without `--scope`
|
||||
- **THEN** operate on global configuration (default behavior)
|
||||
|
||||
#### Scenario: Project scope not yet implemented
|
||||
|
||||
- **WHEN** user executes `openspec config --scope project <subcommand>`
|
||||
- **THEN** display error message: "Project-local config is not yet implemented"
|
||||
- **AND** exit with code 1
|
||||
@@ -20,12 +20,13 @@ The system SHALL provide a `view` command that displays a dashboard overview of
|
||||
|
||||
### Requirement: Summary Section
|
||||
|
||||
The dashboard SHALL display a summary section with key project metrics.
|
||||
The dashboard SHALL display a summary section with key project metrics, including draft change count.
|
||||
|
||||
#### Scenario: Complete summary display
|
||||
|
||||
- **WHEN** dashboard is rendered with specs and changes
|
||||
- **THEN** system shows total number of specifications and requirements
|
||||
- **AND** shows number of draft changes
|
||||
- **AND** shows number of active changes in progress
|
||||
- **AND** shows number of completed changes
|
||||
- **AND** shows overall task progress percentage
|
||||
@@ -46,11 +47,13 @@ The dashboard SHALL show active changes with visual progress indicators.
|
||||
|
||||
### Requirement: Completed Changes Display
|
||||
|
||||
The dashboard SHALL list completed changes in a separate section.
|
||||
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
|
||||
|
||||
> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.
|
||||
|
||||
#### Scenario: Completed changes listing
|
||||
|
||||
- **WHEN** there are completed changes (all tasks done)
|
||||
- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`
|
||||
- **THEN** system shows them with checkmark indicators in a dedicated section
|
||||
|
||||
#### Scenario: Mixed completion states
|
||||
@@ -58,6 +61,12 @@ The dashboard SHALL list completed changes in a separate section.
|
||||
- **WHEN** some changes are complete and others active
|
||||
- **THEN** system separates them into appropriate sections
|
||||
|
||||
#### Scenario: Empty changes not completed
|
||||
|
||||
- **WHEN** a change has no tasks.md or zero tasks defined
|
||||
- **THEN** system does NOT show it in "Completed Changes" section
|
||||
- **AND** shows it in "Draft Changes" section instead
|
||||
|
||||
### Requirement: Specifications Display
|
||||
|
||||
The dashboard SHALL display specifications sorted by requirement count.
|
||||
@@ -103,3 +112,18 @@ The view command SHALL handle errors gracefully.
|
||||
- **WHEN** specs or changes have invalid format
|
||||
- **THEN** system skips invalid items and continues rendering
|
||||
|
||||
### Requirement: Draft Changes Display
|
||||
|
||||
The dashboard SHALL display changes without tasks in a separate "Draft" section.
|
||||
|
||||
#### Scenario: Draft changes listing
|
||||
|
||||
- **WHEN** there are changes with no tasks.md or zero tasks defined
|
||||
- **THEN** system shows them in a "Draft Changes" section
|
||||
- **AND** uses a distinct indicator (e.g., `○`) to show draft status
|
||||
|
||||
#### Scenario: Draft section ordering
|
||||
|
||||
- **WHEN** multiple draft changes exist
|
||||
- **THEN** system sorts them alphabetically by name
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# instruction-loader Specification
|
||||
|
||||
## Purpose
|
||||
The instruction-loader loads instruction templates from schema directories, validates and enriches them with metadata and parameters (such as change context and dependency status), and exposes them for use by downstream services including template retrieval, parameter substitution, and enrichment.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Template Loading
|
||||
The system SHALL load templates from schema directories.
|
||||
|
||||
#### Scenario: Load template from schema directory
|
||||
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
|
||||
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
|
||||
|
||||
#### Scenario: Template file not found
|
||||
- **WHEN** a template file does not exist in the schema's templates directory
|
||||
- **THEN** the system throws an error with the template path
|
||||
|
||||
### Requirement: Change Context Loading
|
||||
The system SHALL load change context combining graph and completion state.
|
||||
|
||||
#### Scenario: Load context for existing change
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change
|
||||
- **THEN** the system returns a context with graph, completed set, schema name, and change info
|
||||
|
||||
#### Scenario: Load context with custom schema
|
||||
- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called
|
||||
- **THEN** the system uses the specified schema instead of default
|
||||
|
||||
#### Scenario: Load context for non-existent change directory
|
||||
- **WHEN** `loadChangeContext` is called for a non-existent change directory
|
||||
- **THEN** the system returns context with empty completed set
|
||||
|
||||
### Requirement: Template Enrichment
|
||||
The system SHALL enrich templates with change-specific context.
|
||||
|
||||
#### Scenario: Include artifact metadata
|
||||
- **WHEN** instructions are generated for an artifact
|
||||
- **THEN** the output includes change name, artifact ID, schema name, and output path
|
||||
|
||||
#### Scenario: Include dependency status
|
||||
- **WHEN** an artifact has dependencies
|
||||
- **THEN** the output shows each dependency with completion status (done/missing)
|
||||
|
||||
#### Scenario: Include unlocked artifacts
|
||||
- **WHEN** instructions are generated
|
||||
- **THEN** the output includes which artifacts become available after this one
|
||||
|
||||
#### Scenario: Root artifact indicator
|
||||
- **WHEN** an artifact has no dependencies
|
||||
- **THEN** the dependency section indicates this is a root artifact
|
||||
|
||||
### Requirement: Status Formatting
|
||||
The system SHALL format change status as readable output.
|
||||
|
||||
#### Scenario: All artifacts completed
|
||||
- **WHEN** all artifacts are completed
|
||||
- **THEN** status shows all artifacts as "done"
|
||||
|
||||
#### Scenario: Mixed completion status
|
||||
- **WHEN** some artifacts are completed
|
||||
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
|
||||
|
||||
#### Scenario: Blocked artifact details
|
||||
- **WHEN** an artifact is blocked
|
||||
- **THEN** status shows which dependencies are missing
|
||||
|
||||
#### Scenario: Include output paths
|
||||
- **WHEN** status is formatted
|
||||
- **THEN** each artifact shows its output path pattern
|
||||
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.2",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -32,12 +32,14 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"schemas",
|
||||
"scripts/postinstall.js",
|
||||
"!dist/**/*.test.js",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/*.map"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint src/",
|
||||
"build": "node build.js",
|
||||
"dev": "tsc --watch",
|
||||
"dev:cli": "pnpm build && node bin/openspec.js",
|
||||
@@ -62,7 +64,9 @@
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@types/node": "^24.2.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -70,7 +74,9 @@
|
||||
"@inquirer/prompts": "^7.8.0",
|
||||
"chalk": "^5.5.0",
|
||||
"commander": "^14.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ora": "^8.2.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+787
-11
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
name: spec-driven
|
||||
version: 1
|
||||
description: Default OpenSpec workflow - proposal → specs → design → tasks
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Initial proposal document outlining the change
|
||||
template: proposal.md
|
||||
requires: []
|
||||
- id: specs
|
||||
generates: "specs/**/*.md"
|
||||
description: Detailed specifications for the change
|
||||
template: spec.md
|
||||
requires:
|
||||
- proposal
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: Technical design document with implementation details
|
||||
template: design.md
|
||||
requires:
|
||||
- proposal
|
||||
- id: tasks
|
||||
generates: tasks.md
|
||||
description: Implementation tasks derived from specs and design
|
||||
template: tasks.md
|
||||
requires:
|
||||
- specs
|
||||
- design
|
||||
@@ -0,0 +1,19 @@
|
||||
## Context
|
||||
|
||||
<!-- Background and current state -->
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
<!-- What this design aims to achieve -->
|
||||
|
||||
**Non-Goals:**
|
||||
<!-- What is explicitly out of scope -->
|
||||
|
||||
## Decisions
|
||||
|
||||
<!-- Key design decisions and rationale -->
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
<!-- Known risks and trade-offs -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change -->
|
||||
|
||||
## What Changes
|
||||
|
||||
<!-- Describe what will change -->
|
||||
|
||||
## Impact
|
||||
|
||||
<!-- List affected areas -->
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <!-- requirement name -->
|
||||
<!-- requirement text -->
|
||||
|
||||
#### Scenario: <!-- scenario name -->
|
||||
- **WHEN** <!-- condition -->
|
||||
- **THEN** <!-- expected outcome -->
|
||||
@@ -0,0 +1,9 @@
|
||||
## 1. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 1.1 <!-- Task description -->
|
||||
- [ ] 1.2 <!-- Task description -->
|
||||
|
||||
## 2. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 2.1 <!-- Task description -->
|
||||
- [ ] 2.2 <!-- Task description -->
|
||||
@@ -0,0 +1,27 @@
|
||||
name: tdd
|
||||
version: 1
|
||||
description: Test-driven development workflow - tests → implementation → docs
|
||||
artifacts:
|
||||
- id: spec
|
||||
generates: spec.md
|
||||
description: Feature specification defining requirements
|
||||
template: spec.md
|
||||
requires: []
|
||||
- id: tests
|
||||
generates: "tests/*.test.ts"
|
||||
description: Test files written before implementation
|
||||
template: test.md
|
||||
requires:
|
||||
- spec
|
||||
- id: implementation
|
||||
generates: "src/*.ts"
|
||||
description: Implementation code to pass the tests
|
||||
template: implementation.md
|
||||
requires:
|
||||
- tests
|
||||
- id: docs
|
||||
generates: "docs/*.md"
|
||||
description: Documentation for the implemented feature
|
||||
template: docs.md
|
||||
requires:
|
||||
- implementation
|
||||
@@ -0,0 +1,15 @@
|
||||
## Overview
|
||||
|
||||
<!-- Feature overview -->
|
||||
|
||||
## Getting Started
|
||||
|
||||
<!-- Quick start guide -->
|
||||
|
||||
## Examples
|
||||
|
||||
<!-- Code examples -->
|
||||
|
||||
## Reference
|
||||
|
||||
<!-- API reference or additional details -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Implementation Notes
|
||||
|
||||
<!-- Technical implementation details -->
|
||||
|
||||
## API
|
||||
|
||||
<!-- Public API documentation -->
|
||||
|
||||
## Usage
|
||||
|
||||
<!-- Usage examples -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Feature: <!-- feature name -->
|
||||
|
||||
<!-- Feature description -->
|
||||
|
||||
## Requirements
|
||||
|
||||
<!-- List of requirements -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- List of acceptance criteria -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Test Plan
|
||||
|
||||
<!-- Describe the testing strategy -->
|
||||
|
||||
## Test Cases
|
||||
|
||||
### <!-- Test case name -->
|
||||
|
||||
- **Given:** <!-- preconditions -->
|
||||
- **When:** <!-- action -->
|
||||
- **Then:** <!-- expected result -->
|
||||
+8
-2
@@ -3,7 +3,6 @@ import { createRequire } from 'module';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { InitCommand } from '../core/init.js';
|
||||
import { AI_TOOLS } from '../core/config.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import { ListCommand } from '../core/list.js';
|
||||
@@ -14,6 +13,8 @@ import { ChangeCommand } from '../commands/change.js';
|
||||
import { ValidateCommand } from '../commands/validate.js';
|
||||
import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
|
||||
|
||||
const program = new Command();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -30,7 +31,7 @@ program.option('--no-color', 'Disable color output');
|
||||
// Apply global flags before any command runs
|
||||
program.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.noColor) {
|
||||
if (opts.color === false) {
|
||||
process.env.NO_COLOR = '1';
|
||||
}
|
||||
});
|
||||
@@ -63,6 +64,7 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
const { InitCommand } = await import('../core/init.js');
|
||||
const initCommand = new InitCommand({
|
||||
tools: options?.tools,
|
||||
});
|
||||
@@ -200,6 +202,7 @@ program
|
||||
});
|
||||
|
||||
registerSpecCommand(program);
|
||||
registerConfigCommand(program);
|
||||
|
||||
// Top-level validate command
|
||||
program
|
||||
@@ -314,4 +317,7 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Register artifact workflow commands (experimental)
|
||||
registerArtifactWorkflowCommands(program);
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* Artifact Workflow CLI Commands (Experimental)
|
||||
*
|
||||
* This file contains all artifact workflow commands in isolation for easy removal.
|
||||
* Commands expose the ArtifactGraph and InstructionLoader APIs to users and agents.
|
||||
*
|
||||
* To remove this feature:
|
||||
* 1. Delete this file
|
||||
* 2. Remove the registerArtifactWorkflowCommands() call from src/cli/index.ts
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import ora from 'ora';
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
loadChangeContext,
|
||||
formatChangeStatus,
|
||||
generateInstructions,
|
||||
listSchemas,
|
||||
getSchemaDir,
|
||||
resolveSchema,
|
||||
ArtifactGraph,
|
||||
type ChangeStatus,
|
||||
type ArtifactInstructions,
|
||||
} from '../core/artifact-graph/index.js';
|
||||
import { createChange, validateChangeName } from '../utils/change-utils.js';
|
||||
|
||||
const DEFAULT_SCHEMA = 'spec-driven';
|
||||
|
||||
/**
|
||||
* Checks if color output is disabled via NO_COLOR env or --no-color flag.
|
||||
*/
|
||||
function isColorDisabled(): boolean {
|
||||
return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the color function based on status.
|
||||
*/
|
||||
function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string {
|
||||
if (isColorDisabled()) {
|
||||
return (text: string) => text;
|
||||
}
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return chalk.green;
|
||||
case 'ready':
|
||||
return chalk.yellow;
|
||||
case 'blocked':
|
||||
return chalk.red;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status indicator for an artifact.
|
||||
*/
|
||||
function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string {
|
||||
const color = getStatusColor(status);
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return color('[x]');
|
||||
case 'ready':
|
||||
return color('[ ]');
|
||||
case 'blocked':
|
||||
return color('[-]');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a change exists and returns available changes if not.
|
||||
* Checks directory existence directly to support scaffolded changes (without proposal.md).
|
||||
*/
|
||||
async function validateChangeExists(
|
||||
changeName: string | undefined,
|
||||
projectRoot: string
|
||||
): Promise<string> {
|
||||
const changesPath = path.join(projectRoot, 'openspec', 'changes');
|
||||
|
||||
// Get all change directories (not just those with proposal.md)
|
||||
const getAvailableChanges = async (): Promise<string[]> => {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
if (!changeName) {
|
||||
const available = await getAvailableChanges();
|
||||
if (available.length === 0) {
|
||||
throw new Error('No changes found. Create one with: openspec new change <name>');
|
||||
}
|
||||
throw new Error(
|
||||
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate change name format to prevent path traversal
|
||||
const nameValidation = validateChangeName(changeName);
|
||||
if (!nameValidation.valid) {
|
||||
throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`);
|
||||
}
|
||||
|
||||
// Check directory existence directly
|
||||
const changePath = path.join(changesPath, changeName);
|
||||
const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
|
||||
|
||||
if (!exists) {
|
||||
const available = await getAvailableChanges();
|
||||
if (available.length === 0) {
|
||||
throw new Error(
|
||||
`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return changeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a schema exists and returns available schemas if not.
|
||||
*/
|
||||
function validateSchemaExists(schemaName: string): string {
|
||||
const schemaDir = getSchemaDir(schemaName);
|
||||
if (!schemaDir) {
|
||||
const availableSchemas = listSchemas();
|
||||
throw new Error(
|
||||
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
|
||||
);
|
||||
}
|
||||
return schemaName;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Status Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface StatusOptions {
|
||||
change?: string;
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
const spinner = ora('Loading change status...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const changeName = await validateChangeExists(options.change, projectRoot);
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
|
||||
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printStatusText(status);
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function printStatusText(status: ChangeStatus): void {
|
||||
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
|
||||
const total = status.artifacts.length;
|
||||
|
||||
console.log(`Change: ${status.changeName}`);
|
||||
console.log(`Schema: ${status.schemaName}`);
|
||||
console.log(`Progress: ${doneCount}/${total} artifacts complete`);
|
||||
console.log();
|
||||
|
||||
for (const artifact of status.artifacts) {
|
||||
const indicator = getStatusIndicator(artifact.status);
|
||||
const color = getStatusColor(artifact.status);
|
||||
let line = `${indicator} ${artifact.id}`;
|
||||
|
||||
if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) {
|
||||
line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`);
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
if (status.isComplete) {
|
||||
console.log();
|
||||
console.log(chalk.green('All artifacts complete!'));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Next Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface NextOptions {
|
||||
change?: string;
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
async function nextCommand(options: NextOptions): Promise<void> {
|
||||
const spinner = ora('Finding next artifacts...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const changeName = await validateChangeExists(options.change, projectRoot);
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
|
||||
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
||||
const ready = context.graph.getNextArtifacts(context.completed);
|
||||
const isComplete = context.graph.isComplete(context.completed);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(ready, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
console.log(chalk.green('All artifacts are complete!'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
console.log('No artifacts are ready. All remaining artifacts are blocked.');
|
||||
console.log('Run `openspec status --change ' + changeName + '` to see blocked dependencies.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Artifacts ready to create:');
|
||||
for (const artifactId of ready) {
|
||||
const color = getStatusColor('ready');
|
||||
console.log(color(` ${artifactId}`));
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Instructions Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface InstructionsOptions {
|
||||
change?: string;
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
async function instructionsCommand(
|
||||
artifactId: string | undefined,
|
||||
options: InstructionsOptions
|
||||
): Promise<void> {
|
||||
const spinner = ora('Generating instructions...').start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const changeName = await validateChangeExists(options.change, projectRoot);
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
|
||||
if (!artifactId) {
|
||||
spinner.stop();
|
||||
const schema = resolveSchema(schemaName);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
const validIds = graph.getAllArtifacts().map((a) => a.id);
|
||||
throw new Error(
|
||||
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
||||
const artifact = context.graph.getArtifact(artifactId);
|
||||
|
||||
if (!artifact) {
|
||||
spinner.stop();
|
||||
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
|
||||
throw new Error(
|
||||
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const instructions = generateInstructions(context, artifactId);
|
||||
const isBlocked = instructions.dependencies.some((d) => !d.done);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(instructions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printInstructionsText(instructions, isBlocked);
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void {
|
||||
if (isBlocked) {
|
||||
console.log(chalk.yellow('Warning: This artifact has unmet dependencies.'));
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`Artifact: ${instructions.artifactId}`);
|
||||
console.log(`Output: ${instructions.outputPath}`);
|
||||
console.log(`Description: ${instructions.description}`);
|
||||
console.log();
|
||||
|
||||
console.log('Dependencies:');
|
||||
if (instructions.dependencies.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const dep of instructions.dependencies) {
|
||||
const status = dep.done ? chalk.green('[done]') : chalk.red('[missing]');
|
||||
console.log(` ${status} ${dep.id}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (instructions.unlocks.length > 0) {
|
||||
console.log('Unlocks:');
|
||||
for (const unlocked of instructions.unlocks) {
|
||||
console.log(` ${unlocked}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log('Template:');
|
||||
console.log('─'.repeat(40));
|
||||
console.log(instructions.template);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Templates Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface TemplatesOptions {
|
||||
schema?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface TemplateInfo {
|
||||
artifactId: string;
|
||||
templatePath: string;
|
||||
source: 'user' | 'package';
|
||||
}
|
||||
|
||||
async function templatesCommand(options: TemplatesOptions): Promise<void> {
|
||||
const spinner = ora('Loading templates...').start();
|
||||
|
||||
try {
|
||||
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
|
||||
const schema = resolveSchema(schemaName);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
const schemaDir = getSchemaDir(schemaName)!;
|
||||
|
||||
// Determine if this is a user override or package built-in
|
||||
const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js');
|
||||
const userSchemasDir = getUserSchemasDir();
|
||||
const isUserOverride = schemaDir.startsWith(userSchemasDir);
|
||||
|
||||
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
|
||||
artifactId: artifact.id,
|
||||
templatePath: path.join(schemaDir, 'templates', artifact.template),
|
||||
source: isUserOverride ? 'user' : 'package',
|
||||
}));
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
const output: Record<string, { path: string; source: string }> = {};
|
||||
for (const t of templates) {
|
||||
output[t.artifactId] = { path: t.templatePath, source: t.source };
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Schema: ${schemaName}`);
|
||||
console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`);
|
||||
console.log();
|
||||
|
||||
for (const t of templates) {
|
||||
console.log(`${t.artifactId}:`);
|
||||
console.log(` ${t.templatePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// New Change Command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface NewChangeOptions {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
|
||||
if (!name) {
|
||||
throw new Error('Missing required argument <name>');
|
||||
}
|
||||
|
||||
const validation = validateChangeName(name);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const spinner = ora(`Creating change '${name}'...`).start();
|
||||
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
await createChange(projectRoot, name);
|
||||
|
||||
// If description provided, create README.md with description
|
||||
if (options.description) {
|
||||
const { promises: fs } = await import('fs');
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
|
||||
const readmePath = path.join(changeDir, 'README.md');
|
||||
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to create change '${name}'`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Command Registration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Registers all artifact workflow commands on the given program.
|
||||
* All commands are marked as experimental in their help text.
|
||||
*/
|
||||
export function registerArtifactWorkflowCommands(program: Command): void {
|
||||
// Status command
|
||||
program
|
||||
.command('status')
|
||||
.description('[Experimental] Display artifact completion status for a change')
|
||||
.option('--change <id>', 'Change name to show status for')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StatusOptions) => {
|
||||
try {
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Next command
|
||||
program
|
||||
.command('next')
|
||||
.description('[Experimental] Show artifacts ready to be created')
|
||||
.option('--change <id>', 'Change name to check')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON array of ready artifact IDs')
|
||||
.action(async (options: NextOptions) => {
|
||||
try {
|
||||
await nextCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Instructions command
|
||||
program
|
||||
.command('instructions [artifact]')
|
||||
.description('[Experimental] Output enriched instructions for creating an artifact')
|
||||
.option('--change <id>', 'Change name')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
|
||||
try {
|
||||
await instructionsCommand(artifactId, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Templates command
|
||||
program
|
||||
.command('templates')
|
||||
.description('[Experimental] Show resolved template paths for all artifacts in a schema')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
|
||||
.action(async (options: TemplatesOptions) => {
|
||||
try {
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// New command group with change subcommand
|
||||
const newCmd = program.command('new').description('[Experimental] Create new items');
|
||||
|
||||
newCmd
|
||||
.command('change <name>')
|
||||
.description('[Experimental] Create a new change directory')
|
||||
.option('--description <text>', 'Description to add to README.md')
|
||||
.action(async (name: string, options: NewChangeOptions) => {
|
||||
try {
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { JsonConverter } from '../core/converters/json-converter.js';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import { ChangeParser } from '../core/parsers/change-parser.js';
|
||||
@@ -30,9 +29,10 @@ export class ChangeCommand {
|
||||
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
|
||||
|
||||
if (!changeName) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const changes = await this.getActiveChanges(changesPath);
|
||||
if (canPrompt && changes.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const selected = await select({
|
||||
message: 'Select a change to show',
|
||||
choices: changes.map(id => ({ name: id, value: id })),
|
||||
@@ -186,9 +186,10 @@ export class ChangeCommand {
|
||||
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
|
||||
|
||||
if (!changeName) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const changes = await getActiveChangeIds();
|
||||
if (canPrompt && changes.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const selected = await select({
|
||||
message: 'Select a change to validate',
|
||||
choices: changes.map(id => ({ name: id, value: id })),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import ora from 'ora';
|
||||
import { confirm } from '@inquirer/prompts';
|
||||
import { CompletionFactory } from '../core/completions/factory.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { detectShell, SupportedShell } from '../utils/shell-detection.js';
|
||||
@@ -179,6 +178,7 @@ export class CompletionCommand {
|
||||
|
||||
// Prompt for confirmation unless --yes flag is provided
|
||||
if (!skipConfirmation) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const confirmed = await confirm({
|
||||
message: 'Remove OpenSpec configuration from ~/.zshrc?',
|
||||
default: false,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Command } from 'commander';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
getGlobalConfigPath,
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
GlobalConfig,
|
||||
} from '../core/global-config.js';
|
||||
import {
|
||||
getNestedValue,
|
||||
setNestedValue,
|
||||
deleteNestedValue,
|
||||
coerceValue,
|
||||
formatValueYaml,
|
||||
validateConfigKeyPath,
|
||||
validateConfig,
|
||||
DEFAULT_CONFIG,
|
||||
} from '../core/config-schema.js';
|
||||
|
||||
/**
|
||||
* Register the config command and all its subcommands.
|
||||
*
|
||||
* @param program - The Commander program instance
|
||||
*/
|
||||
export function registerConfigCommand(program: Command): void {
|
||||
const configCmd = program
|
||||
.command('config')
|
||||
.description('View and modify global OpenSpec configuration')
|
||||
.option('--scope <scope>', 'Config scope (only "global" supported currently)')
|
||||
.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.scope && opts.scope !== 'global') {
|
||||
console.error('Error: Project-local config is not yet implemented');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// config path
|
||||
configCmd
|
||||
.command('path')
|
||||
.description('Show config file location')
|
||||
.action(() => {
|
||||
console.log(getGlobalConfigPath());
|
||||
});
|
||||
|
||||
// config list
|
||||
configCmd
|
||||
.command('list')
|
||||
.description('Show all current settings')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action((options: { json?: boolean }) => {
|
||||
const config = getGlobalConfig();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
} else {
|
||||
console.log(formatValueYaml(config));
|
||||
}
|
||||
});
|
||||
|
||||
// config get
|
||||
configCmd
|
||||
.command('get <key>')
|
||||
.description('Get a specific value (raw, scriptable)')
|
||||
.action((key: string) => {
|
||||
const config = getGlobalConfig();
|
||||
const value = getNestedValue(config as Record<string, unknown>, key);
|
||||
|
||||
if (value === undefined) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
console.log(JSON.stringify(value));
|
||||
} else {
|
||||
console.log(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// config set
|
||||
configCmd
|
||||
.command('set <key> <value>')
|
||||
.description('Set a value (auto-coerce types)')
|
||||
.option('--string', 'Force value to be stored as string')
|
||||
.option('--allow-unknown', 'Allow setting unknown keys')
|
||||
.action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => {
|
||||
const allowUnknown = Boolean(options.allowUnknown);
|
||||
const keyValidation = validateConfigKeyPath(key);
|
||||
if (!keyValidation.valid && !allowUnknown) {
|
||||
const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
|
||||
console.error(`Error: Invalid configuration key "${key}".${reason}`);
|
||||
console.error('Use "openspec config list" to see available keys.');
|
||||
console.error('Pass --allow-unknown to bypass this check.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const coercedValue = coerceValue(value, options.string || false);
|
||||
|
||||
// Create a copy to validate before saving
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
setNestedValue(newConfig, key, coercedValue);
|
||||
|
||||
// Validate the new config
|
||||
const validation = validateConfig(newConfig);
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes and save
|
||||
setNestedValue(config, key, coercedValue);
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
|
||||
const displayValue =
|
||||
typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue);
|
||||
console.log(`Set ${key} = ${displayValue}`);
|
||||
});
|
||||
|
||||
// config unset
|
||||
configCmd
|
||||
.command('unset <key>')
|
||||
.description('Remove a key (revert to default)')
|
||||
.action((key: string) => {
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const existed = deleteNestedValue(config, key);
|
||||
|
||||
if (existed) {
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
console.log(`Unset ${key} (reverted to default)`);
|
||||
} else {
|
||||
console.log(`Key "${key}" was not set`);
|
||||
}
|
||||
});
|
||||
|
||||
// config reset
|
||||
configCmd
|
||||
.command('reset')
|
||||
.description('Reset configuration to defaults')
|
||||
.option('--all', 'Reset all configuration (required)')
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (options: { all?: boolean; yes?: boolean }) => {
|
||||
if (!options.all) {
|
||||
console.error('Error: --all flag is required for reset');
|
||||
console.error('Usage: openspec config reset --all [-y]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const confirmed = await confirm({
|
||||
message: 'Reset all configuration to defaults?',
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Reset cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
console.log('Configuration reset to defaults');
|
||||
});
|
||||
|
||||
// config edit
|
||||
configCmd
|
||||
.command('edit')
|
||||
.description('Open config in $EDITOR')
|
||||
.action(async () => {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL;
|
||||
|
||||
if (!editor) {
|
||||
console.error('Error: No editor configured');
|
||||
console.error('Set the EDITOR or VISUAL environment variable to your preferred editor');
|
||||
console.error('Example: export EDITOR=vim');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
// Ensure config file exists with defaults
|
||||
if (!fs.existsSync(configPath)) {
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
}
|
||||
|
||||
// Spawn editor and wait for it to close
|
||||
// Avoid shell parsing to correctly handle paths with spaces in both
|
||||
// the editor path and config path
|
||||
const child = spawn(editor, [configPath], {
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Editor exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsedConfig = JSON.parse(rawConfig);
|
||||
const validation = validateConfig(parsedConfig);
|
||||
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.error(`Error: Config file not found at ${configPath}`);
|
||||
} else if (error instanceof SyntaxError) {
|
||||
console.error(`Error: Invalid JSON in ${configPath}`);
|
||||
console.error(error.message);
|
||||
} else {
|
||||
console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { select } from '@inquirer/prompts';
|
||||
import path from 'path';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
|
||||
@@ -13,11 +12,12 @@ const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
|
||||
|
||||
export class ShowCommand {
|
||||
async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {
|
||||
const interactive = isInteractive(options.noInteractive);
|
||||
const interactive = isInteractive(options);
|
||||
const typeOverride = this.normalizeType(options.type);
|
||||
|
||||
if (!itemName) {
|
||||
if (interactive) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const type = await select<ItemType>({
|
||||
message: 'What would you like to show?',
|
||||
choices: [
|
||||
@@ -44,6 +44,7 @@ export class ShowCommand {
|
||||
}
|
||||
|
||||
private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
if (type === 'change') {
|
||||
const changes = await getActiveChangeIds();
|
||||
if (changes.length === 0) {
|
||||
@@ -135,5 +136,3 @@ export class ShowCommand {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from 'path';
|
||||
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import type { Spec } from '../core/schemas/index.js';
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { getSpecIds } from '../utils/item-discovery.js';
|
||||
|
||||
@@ -70,9 +69,10 @@ export class SpecCommand {
|
||||
|
||||
async show(specId?: string, options: ShowOptions = {}): Promise<void> {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to show',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
@@ -204,9 +204,10 @@ export function registerSpecCommand(rootProgram: typeof program) {
|
||||
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to validate',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
@@ -247,4 +248,4 @@ export function registerSpecCommand(rootProgram: typeof program) {
|
||||
});
|
||||
|
||||
return specCommand;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { select } from '@inquirer/prompts';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
||||
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
|
||||
import { nearestMatches } from '../utils/match.js';
|
||||
|
||||
@@ -16,6 +15,7 @@ interface ExecuteOptions {
|
||||
strict?: boolean;
|
||||
json?: boolean;
|
||||
noInteractive?: boolean;
|
||||
interactive?: boolean; // Commander sets this to false when --no-interactive is used
|
||||
concurrency?: string;
|
||||
}
|
||||
|
||||
@@ -29,14 +29,14 @@ interface BulkItemResult {
|
||||
|
||||
export class ValidateCommand {
|
||||
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
|
||||
const interactive = isInteractive(options.noInteractive);
|
||||
const interactive = isInteractive(options);
|
||||
|
||||
// Handle bulk flags first
|
||||
if (options.all || options.changes || options.specs) {
|
||||
await this.runBulkValidation({
|
||||
changes: !!options.all || !!options.changes,
|
||||
specs: !!options.all || !!options.specs,
|
||||
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
|
||||
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export class ValidateCommand {
|
||||
}
|
||||
|
||||
private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const choice = await select({
|
||||
message: 'What would you like to validate?',
|
||||
choices: [
|
||||
@@ -180,8 +181,8 @@ export class ValidateCommand {
|
||||
bullets.forEach(b => console.error(` ${b}`));
|
||||
}
|
||||
|
||||
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
|
||||
const spinner = !opts.json ? ora('Validating...').start() : undefined;
|
||||
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {
|
||||
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
|
||||
const [changeIds, specIds] = await Promise.all([
|
||||
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
|
||||
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
|
||||
@@ -212,6 +213,28 @@ export class ValidateCommand {
|
||||
});
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
spinner?.stop();
|
||||
|
||||
const summary = {
|
||||
totals: { items: 0, passed: 0, failed: 0 },
|
||||
byType: {
|
||||
...(scope.changes ? { change: { items: 0, passed: 0, failed: 0 } } : {}),
|
||||
...(scope.specs ? { spec: { items: 0, passed: 0, failed: 0 } } : {}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (opts.json) {
|
||||
const out = { items: [] as BulkItemResult[], summary, version: '1.0' };
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} else {
|
||||
console.log('No items found to validate.');
|
||||
}
|
||||
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const results: BulkItemResult[] = [];
|
||||
let index = 0;
|
||||
let running = 0;
|
||||
@@ -301,5 +324,3 @@ function getPlannedType(index: number, changeIds: string[], specIds: string[]):
|
||||
if (specIndex >= 0 && specIndex < specIds.length) return 'spec';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+27
-8
@@ -1,7 +1,5 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { select, confirm } from '@inquirer/prompts';
|
||||
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';
|
||||
@@ -125,6 +123,7 @@ export class ArchiveCommand {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const proceed = await confirm({
|
||||
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
|
||||
default: false
|
||||
@@ -149,6 +148,7 @@ export class ArchiveCommand {
|
||||
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
|
||||
if (incompleteTasks > 0) {
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const proceed = await confirm({
|
||||
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
|
||||
default: false
|
||||
@@ -179,6 +179,7 @@ export class ArchiveCommand {
|
||||
|
||||
let shouldUpdateSpecs = true;
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
shouldUpdateSpecs = await confirm({
|
||||
message: 'Proceed with spec updates?',
|
||||
default: true
|
||||
@@ -256,6 +257,7 @@ export class ArchiveCommand {
|
||||
}
|
||||
|
||||
private async selectChange(changesDir: string): Promise<string | null> {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
// Get all directories in changes (excluding archive)
|
||||
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
||||
const changeDirs = entries
|
||||
@@ -444,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);
|
||||
}
|
||||
|
||||
@@ -495,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,167 @@
|
||||
import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './types.js';
|
||||
import { loadSchema, parseSchema } from './schema.js';
|
||||
|
||||
/**
|
||||
* Represents an artifact dependency graph.
|
||||
* Provides methods for querying build order, ready artifacts, and completion status.
|
||||
*/
|
||||
export class ArtifactGraph {
|
||||
private artifacts: Map<string, Artifact>;
|
||||
private schema: SchemaYaml;
|
||||
|
||||
private constructor(schema: SchemaYaml) {
|
||||
this.schema = schema;
|
||||
this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ArtifactGraph from a YAML file path.
|
||||
*/
|
||||
static fromYaml(filePath: string): ArtifactGraph {
|
||||
const schema = loadSchema(filePath);
|
||||
return new ArtifactGraph(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ArtifactGraph from YAML content string.
|
||||
*/
|
||||
static fromYamlContent(yamlContent: string): ArtifactGraph {
|
||||
const schema = parseSchema(yamlContent);
|
||||
return new ArtifactGraph(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ArtifactGraph from a pre-validated schema object.
|
||||
*/
|
||||
static fromSchema(schema: SchemaYaml): ArtifactGraph {
|
||||
return new ArtifactGraph(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single artifact by ID.
|
||||
*/
|
||||
getArtifact(id: string): Artifact | undefined {
|
||||
return this.artifacts.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all artifacts in the graph.
|
||||
*/
|
||||
getAllArtifacts(): Artifact[] {
|
||||
return Array.from(this.artifacts.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the schema name.
|
||||
*/
|
||||
getName(): string {
|
||||
return this.schema.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the schema version.
|
||||
*/
|
||||
getVersion(): number {
|
||||
return this.schema.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the topological build order using Kahn's algorithm.
|
||||
* Returns artifact IDs in the order they should be built.
|
||||
*/
|
||||
getBuildOrder(): string[] {
|
||||
const inDegree = new Map<string, number>();
|
||||
const dependents = new Map<string, string[]>();
|
||||
|
||||
// Initialize all artifacts
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
inDegree.set(artifact.id, artifact.requires.length);
|
||||
dependents.set(artifact.id, []);
|
||||
}
|
||||
|
||||
// Build reverse adjacency (who depends on whom)
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
for (const req of artifact.requires) {
|
||||
dependents.get(req)!.push(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Start with roots (in-degree 0), sorted for determinism
|
||||
const queue = [...this.artifacts.keys()]
|
||||
.filter(id => inDegree.get(id) === 0)
|
||||
.sort();
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
result.push(current);
|
||||
|
||||
// Collect newly ready artifacts, then sort before adding
|
||||
const newlyReady: string[] = [];
|
||||
for (const dep of dependents.get(current)!) {
|
||||
const newDegree = inDegree.get(dep)! - 1;
|
||||
inDegree.set(dep, newDegree);
|
||||
if (newDegree === 0) {
|
||||
newlyReady.push(dep);
|
||||
}
|
||||
}
|
||||
queue.push(...newlyReady.sort());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets artifacts that are ready to be created (all dependencies completed).
|
||||
*/
|
||||
getNextArtifacts(completed: CompletedSet): string[] {
|
||||
const ready: string[] = [];
|
||||
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
if (completed.has(artifact.id)) {
|
||||
continue; // Already completed
|
||||
}
|
||||
|
||||
const allDepsCompleted = artifact.requires.every(req => completed.has(req));
|
||||
if (allDepsCompleted) {
|
||||
ready.push(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for deterministic ordering
|
||||
return ready.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all artifacts in the graph are completed.
|
||||
*/
|
||||
isComplete(completed: CompletedSet): boolean {
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
if (!completed.has(artifact.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets blocked artifacts and their unmet dependencies.
|
||||
*/
|
||||
getBlocked(completed: CompletedSet): BlockedArtifacts {
|
||||
const blocked: BlockedArtifacts = {};
|
||||
|
||||
for (const artifact of this.artifacts.values()) {
|
||||
if (completed.has(artifact.id)) {
|
||||
continue; // Already completed
|
||||
}
|
||||
|
||||
const unmetDeps = artifact.requires.filter(req => !completed.has(req));
|
||||
if (unmetDeps.length > 0) {
|
||||
blocked[artifact.id] = unmetDeps.sort();
|
||||
}
|
||||
}
|
||||
|
||||
return blocked;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Types
|
||||
export {
|
||||
ArtifactSchema,
|
||||
SchemaYamlSchema,
|
||||
type Artifact,
|
||||
type SchemaYaml,
|
||||
type CompletedSet,
|
||||
type BlockedArtifacts,
|
||||
} from './types.js';
|
||||
|
||||
// Schema loading and validation
|
||||
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
||||
|
||||
// Graph operations
|
||||
export { ArtifactGraph } from './graph.js';
|
||||
|
||||
// State detection
|
||||
export { detectCompleted } from './state.js';
|
||||
|
||||
// Schema resolution
|
||||
export {
|
||||
resolveSchema,
|
||||
listSchemas,
|
||||
getSchemaDir,
|
||||
getPackageSchemasDir,
|
||||
getUserSchemasDir,
|
||||
SchemaLoadError,
|
||||
} from './resolver.js';
|
||||
|
||||
// Instruction loading
|
||||
export {
|
||||
loadTemplate,
|
||||
loadChangeContext,
|
||||
generateInstructions,
|
||||
formatChangeStatus,
|
||||
TemplateLoadError,
|
||||
type ChangeContext,
|
||||
type ArtifactInstructions,
|
||||
type DependencyStatus,
|
||||
type ArtifactStatus,
|
||||
type ChangeStatus,
|
||||
} from './instruction-loader.js';
|
||||
@@ -0,0 +1,269 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getSchemaDir, resolveSchema } from './resolver.js';
|
||||
import { ArtifactGraph } from './graph.js';
|
||||
import { detectCompleted } from './state.js';
|
||||
import type { Artifact, CompletedSet } from './types.js';
|
||||
|
||||
/**
|
||||
* Error thrown when loading a template fails.
|
||||
*/
|
||||
export class TemplateLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly templatePath: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TemplateLoadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change context containing graph, completion state, and metadata.
|
||||
*/
|
||||
export interface ChangeContext {
|
||||
/** The artifact dependency graph */
|
||||
graph: ArtifactGraph;
|
||||
/** Set of completed artifact IDs */
|
||||
completed: CompletedSet;
|
||||
/** Schema name being used */
|
||||
schemaName: string;
|
||||
/** Change name */
|
||||
changeName: string;
|
||||
/** Path to the change directory */
|
||||
changeDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriched instructions for creating an artifact.
|
||||
*/
|
||||
export interface ArtifactInstructions {
|
||||
/** Change name */
|
||||
changeName: string;
|
||||
/** Artifact ID */
|
||||
artifactId: string;
|
||||
/** Schema name */
|
||||
schemaName: string;
|
||||
/** Output path pattern (e.g., "proposal.md") */
|
||||
outputPath: string;
|
||||
/** Artifact description */
|
||||
description: string;
|
||||
/** Template content */
|
||||
template: string;
|
||||
/** Dependencies with completion status */
|
||||
dependencies: DependencyStatus[];
|
||||
/** Artifacts that become available after completing this one */
|
||||
unlocks: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency status information.
|
||||
*/
|
||||
export interface DependencyStatus {
|
||||
/** Artifact ID */
|
||||
id: string;
|
||||
/** Whether the dependency is completed */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a single artifact in the workflow.
|
||||
*/
|
||||
export interface ArtifactStatus {
|
||||
/** Artifact ID */
|
||||
id: string;
|
||||
/** Output path pattern */
|
||||
outputPath: string;
|
||||
/** Status: done, ready, or blocked */
|
||||
status: 'done' | 'ready' | 'blocked';
|
||||
/** Missing dependencies (only for blocked) */
|
||||
missingDeps?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted change status.
|
||||
*/
|
||||
export interface ChangeStatus {
|
||||
/** Change name */
|
||||
changeName: string;
|
||||
/** Schema name */
|
||||
schemaName: string;
|
||||
/** Whether all artifacts are complete */
|
||||
isComplete: boolean;
|
||||
/** Status of each artifact */
|
||||
artifacts: ArtifactStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a template from a schema's templates directory.
|
||||
*
|
||||
* @param schemaName - Schema name (e.g., "spec-driven")
|
||||
* @param templatePath - Relative path within the templates directory (e.g., "proposal.md")
|
||||
* @returns The template content
|
||||
* @throws TemplateLoadError if the template cannot be loaded
|
||||
*/
|
||||
export function loadTemplate(schemaName: string, templatePath: string): string {
|
||||
const schemaDir = getSchemaDir(schemaName);
|
||||
if (!schemaDir) {
|
||||
throw new TemplateLoadError(
|
||||
`Schema '${schemaName}' not found`,
|
||||
templatePath
|
||||
);
|
||||
}
|
||||
|
||||
const fullPath = path.join(schemaDir, 'templates', templatePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new TemplateLoadError(
|
||||
`Template not found: ${fullPath}`,
|
||||
fullPath
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
} catch (err) {
|
||||
const ioError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new TemplateLoadError(
|
||||
`Failed to read template: ${ioError.message}`,
|
||||
fullPath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads change context combining graph and completion state.
|
||||
*
|
||||
* @param projectRoot - Project root directory
|
||||
* @param changeName - Change name
|
||||
* @param schemaName - Optional schema name (defaults to "spec-driven")
|
||||
* @returns Change context with graph, completed set, and metadata
|
||||
*/
|
||||
export function loadChangeContext(
|
||||
projectRoot: string,
|
||||
changeName: string,
|
||||
schemaName: string = 'spec-driven'
|
||||
): ChangeContext {
|
||||
const schema = resolveSchema(schemaName);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
|
||||
const completed = detectCompleted(graph, changeDir);
|
||||
|
||||
return {
|
||||
graph,
|
||||
completed,
|
||||
schemaName,
|
||||
changeName,
|
||||
changeDir,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates enriched instructions for creating an artifact.
|
||||
*
|
||||
* @param context - Change context
|
||||
* @param artifactId - Artifact ID to generate instructions for
|
||||
* @returns Enriched artifact instructions
|
||||
* @throws Error if artifact not found
|
||||
*/
|
||||
export function generateInstructions(
|
||||
context: ChangeContext,
|
||||
artifactId: string
|
||||
): ArtifactInstructions {
|
||||
const artifact = context.graph.getArtifact(artifactId);
|
||||
if (!artifact) {
|
||||
throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);
|
||||
}
|
||||
|
||||
const template = loadTemplate(context.schemaName, artifact.template);
|
||||
const dependencies = getDependencyStatus(artifact, context.completed);
|
||||
const unlocks = getUnlockedArtifacts(context.graph, artifactId);
|
||||
|
||||
return {
|
||||
changeName: context.changeName,
|
||||
artifactId: artifact.id,
|
||||
schemaName: context.schemaName,
|
||||
outputPath: artifact.generates,
|
||||
description: artifact.description,
|
||||
template,
|
||||
dependencies,
|
||||
unlocks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets dependency status for an artifact.
|
||||
*/
|
||||
function getDependencyStatus(
|
||||
artifact: Artifact,
|
||||
completed: CompletedSet
|
||||
): DependencyStatus[] {
|
||||
return artifact.requires.map(id => ({
|
||||
id,
|
||||
done: completed.has(id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets artifacts that become available after completing the given artifact.
|
||||
*/
|
||||
function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[] {
|
||||
const unlocks: string[] = [];
|
||||
|
||||
for (const artifact of graph.getAllArtifacts()) {
|
||||
if (artifact.requires.includes(artifactId)) {
|
||||
unlocks.push(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
return unlocks.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the status of all artifacts in a change.
|
||||
*
|
||||
* @param context - Change context
|
||||
* @returns Formatted change status
|
||||
*/
|
||||
export function formatChangeStatus(context: ChangeContext): ChangeStatus {
|
||||
const artifacts = context.graph.getAllArtifacts();
|
||||
const ready = new Set(context.graph.getNextArtifacts(context.completed));
|
||||
const blocked = context.graph.getBlocked(context.completed);
|
||||
|
||||
const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => {
|
||||
if (context.completed.has(artifact.id)) {
|
||||
return {
|
||||
id: artifact.id,
|
||||
outputPath: artifact.generates,
|
||||
status: 'done' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.has(artifact.id)) {
|
||||
return {
|
||||
id: artifact.id,
|
||||
outputPath: artifact.generates,
|
||||
status: 'ready' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: artifact.id,
|
||||
outputPath: artifact.generates,
|
||||
status: 'blocked' as const,
|
||||
missingDeps: blocked[artifact.id] ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by build order for consistent output
|
||||
const buildOrder = context.graph.getBuildOrder();
|
||||
const orderMap = new Map(buildOrder.map((id, idx) => [id, idx]));
|
||||
artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||
|
||||
return {
|
||||
changeName: context.changeName,
|
||||
schemaName: context.schemaName,
|
||||
isComplete: context.graph.isComplete(context.completed),
|
||||
artifacts: artifactStatuses,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getGlobalDataDir } from '../global-config.js';
|
||||
import { parseSchema, SchemaValidationError } from './schema.js';
|
||||
import type { SchemaYaml } from './types.js';
|
||||
|
||||
/**
|
||||
* Error thrown when loading a schema fails.
|
||||
*/
|
||||
export class SchemaLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly schemaPath: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SchemaLoadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the package's built-in schemas directory path.
|
||||
* Uses import.meta.url to resolve relative to the current module.
|
||||
*/
|
||||
export function getPackageSchemasDir(): string {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
// Navigate from dist/core/artifact-graph/ to package root's schemas/
|
||||
return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user's schema override directory path.
|
||||
*/
|
||||
export function getUserSchemasDir(): string {
|
||||
return path.join(getGlobalDataDir(), 'schemas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a schema name to its directory path.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
|
||||
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
|
||||
*
|
||||
* @param name - Schema name (e.g., "spec-driven")
|
||||
* @returns The path to the schema directory, or null if not found
|
||||
*/
|
||||
export function getSchemaDir(name: string): string | null {
|
||||
// 1. Check user override directory
|
||||
const userDir = path.join(getUserSchemasDir(), name);
|
||||
const userSchemaPath = path.join(userDir, 'schema.yaml');
|
||||
if (fs.existsSync(userSchemaPath)) {
|
||||
return userDir;
|
||||
}
|
||||
|
||||
// 2. Check package built-in directory
|
||||
const packageDir = path.join(getPackageSchemasDir(), name);
|
||||
const packageSchemaPath = path.join(packageDir, 'schema.yaml');
|
||||
if (fs.existsSync(packageSchemaPath)) {
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a schema name to a SchemaYaml object.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
|
||||
* 2. Package built-in: <package>/schemas/<name>/schema.yaml
|
||||
*
|
||||
* @param name - Schema name (e.g., "spec-driven")
|
||||
* @returns The resolved schema object
|
||||
* @throws Error if schema is not found in any location
|
||||
*/
|
||||
export function resolveSchema(name: string): SchemaYaml {
|
||||
// Normalize name (remove .yaml extension if provided)
|
||||
const normalizedName = name.replace(/\.ya?ml$/, '');
|
||||
|
||||
const schemaDir = getSchemaDir(normalizedName);
|
||||
if (!schemaDir) {
|
||||
const availableSchemas = listSchemas();
|
||||
throw new Error(
|
||||
`Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const schemaPath = path.join(schemaDir, 'schema.yaml');
|
||||
|
||||
// Load and parse the schema
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(schemaPath, 'utf-8');
|
||||
} catch (err) {
|
||||
const ioError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new SchemaLoadError(
|
||||
`Failed to read schema at '${schemaPath}': ${ioError.message}`,
|
||||
schemaPath,
|
||||
ioError
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return parseSchema(content);
|
||||
} catch (err) {
|
||||
if (err instanceof SchemaValidationError) {
|
||||
throw new SchemaLoadError(
|
||||
`Invalid schema at '${schemaPath}': ${err.message}`,
|
||||
schemaPath,
|
||||
err
|
||||
);
|
||||
}
|
||||
const parseError = err instanceof Error ? err : new Error(String(err));
|
||||
throw new SchemaLoadError(
|
||||
`Failed to parse schema at '${schemaPath}': ${parseError.message}`,
|
||||
schemaPath,
|
||||
parseError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available schema names.
|
||||
* Combines user override and package built-in schemas.
|
||||
*/
|
||||
export function listSchemas(): string[] {
|
||||
const schemas = new Set<string>();
|
||||
|
||||
// Add package built-in schemas
|
||||
const packageDir = getPackageSchemasDir();
|
||||
if (fs.existsSync(packageDir)) {
|
||||
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
schemas.add(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user override schemas (may override package schemas)
|
||||
const userDir = getUserSchemasDir();
|
||||
if (fs.existsSync(userDir)) {
|
||||
for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
schemas.add(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(schemas).sort();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SchemaValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates an artifact schema from a YAML file.
|
||||
*/
|
||||
export function loadSchema(filePath: string): SchemaYaml {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return parseSchema(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates an artifact schema from YAML content.
|
||||
*/
|
||||
export function parseSchema(yamlContent: string): SchemaYaml {
|
||||
const parsed = parseYaml(yamlContent);
|
||||
|
||||
// Validate with Zod
|
||||
const result = SchemaYamlSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
throw new SchemaValidationError(`Invalid schema: ${errors}`);
|
||||
}
|
||||
|
||||
const schema = result.data;
|
||||
|
||||
// Check for duplicate artifact IDs
|
||||
validateNoDuplicateIds(schema.artifacts);
|
||||
|
||||
// Check that all requires references are valid
|
||||
validateRequiresReferences(schema.artifacts);
|
||||
|
||||
// Check for cycles
|
||||
validateNoCycles(schema.artifacts);
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that there are no duplicate artifact IDs.
|
||||
*/
|
||||
function validateNoDuplicateIds(artifacts: Artifact[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const artifact of artifacts) {
|
||||
if (seen.has(artifact.id)) {
|
||||
throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);
|
||||
}
|
||||
seen.add(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all `requires` references point to valid artifact IDs.
|
||||
*/
|
||||
function validateRequiresReferences(artifacts: Artifact[]): void {
|
||||
const validIds = new Set(artifacts.map(a => a.id));
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
for (const req of artifact.requires) {
|
||||
if (!validIds.has(req)) {
|
||||
throw new SchemaValidationError(
|
||||
`Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that there are no cyclic dependencies.
|
||||
* Uses DFS to detect cycles and reports the full cycle path.
|
||||
*/
|
||||
function validateNoCycles(artifacts: Artifact[]): void {
|
||||
const artifactMap = new Map(artifacts.map(a => [a.id, a]));
|
||||
const visited = new Set<string>();
|
||||
const inStack = new Set<string>();
|
||||
const parent = new Map<string, string>();
|
||||
|
||||
function dfs(id: string): string | null {
|
||||
visited.add(id);
|
||||
inStack.add(id);
|
||||
|
||||
const artifact = artifactMap.get(id);
|
||||
if (!artifact) return null;
|
||||
|
||||
for (const dep of artifact.requires) {
|
||||
if (!visited.has(dep)) {
|
||||
parent.set(dep, id);
|
||||
const cycle = dfs(dep);
|
||||
if (cycle) return cycle;
|
||||
} else if (inStack.has(dep)) {
|
||||
// Found a cycle - reconstruct the path
|
||||
const cyclePath = [dep];
|
||||
let current = id;
|
||||
while (current !== dep) {
|
||||
cyclePath.unshift(current);
|
||||
current = parent.get(current)!;
|
||||
}
|
||||
cyclePath.unshift(dep);
|
||||
return cyclePath.join(' → ');
|
||||
}
|
||||
}
|
||||
|
||||
inStack.delete(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
if (!visited.has(artifact.id)) {
|
||||
const cycle = dfs(artifact.id);
|
||||
if (cycle) {
|
||||
throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import type { CompletedSet } from './types.js';
|
||||
import type { ArtifactGraph } from './graph.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
/**
|
||||
* Detects which artifacts are completed by checking file existence in the change directory.
|
||||
* Returns a Set of completed artifact IDs.
|
||||
*
|
||||
* @param graph - The artifact graph to check
|
||||
* @param changeDir - The change directory to scan for files
|
||||
* @returns Set of artifact IDs whose generated files exist
|
||||
*/
|
||||
export function detectCompleted(graph: ArtifactGraph, changeDir: string): CompletedSet {
|
||||
const completed = new Set<string>();
|
||||
|
||||
// Handle missing change directory gracefully
|
||||
if (!fs.existsSync(changeDir)) {
|
||||
return completed;
|
||||
}
|
||||
|
||||
for (const artifact of graph.getAllArtifacts()) {
|
||||
if (isArtifactComplete(artifact.generates, changeDir)) {
|
||||
completed.add(artifact.id);
|
||||
}
|
||||
}
|
||||
|
||||
return completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an artifact is complete by checking if its generated file(s) exist.
|
||||
* Supports both simple paths and glob patterns.
|
||||
*/
|
||||
function isArtifactComplete(generates: string, changeDir: string): boolean {
|
||||
const fullPattern = path.join(changeDir, generates);
|
||||
|
||||
// Check if it's a glob pattern
|
||||
if (isGlobPattern(generates)) {
|
||||
return hasGlobMatches(fullPattern);
|
||||
}
|
||||
|
||||
// Simple file path - check if file exists
|
||||
return fs.existsSync(fullPattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path contains glob pattern characters.
|
||||
*/
|
||||
function isGlobPattern(pattern: string): boolean {
|
||||
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a glob pattern has any matches.
|
||||
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
|
||||
*/
|
||||
function hasGlobMatches(pattern: string): boolean {
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
|
||||
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
|
||||
return matches.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Artifact definition schema
|
||||
export const ArtifactSchema = z.object({
|
||||
id: z.string().min(1, { error: 'Artifact ID is required' }),
|
||||
generates: z.string().min(1, { error: 'generates field is required' }),
|
||||
description: z.string(),
|
||||
template: z.string().min(1, { error: 'template field is required' }),
|
||||
requires: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
// Full schema YAML structure
|
||||
export const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1, { error: 'Schema name is required' }),
|
||||
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
|
||||
});
|
||||
|
||||
// Derived TypeScript types
|
||||
export type Artifact = z.infer<typeof ArtifactSchema>;
|
||||
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
|
||||
|
||||
// Runtime state types (not Zod - internal only)
|
||||
|
||||
// Slice 1: Simple completion tracking via filesystem
|
||||
export type CompletedSet = Set<string>;
|
||||
|
||||
// Return type for blocked query
|
||||
export interface BlockedArtifacts {
|
||||
[artifactId: string]: string[];
|
||||
}
|
||||
|
||||
@@ -288,4 +288,77 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'config',
|
||||
description: 'View and modify global OpenSpec configuration',
|
||||
flags: [
|
||||
{
|
||||
name: 'scope',
|
||||
description: 'Config scope (only "global" supported currently)',
|
||||
takesValue: true,
|
||||
values: ['global'],
|
||||
},
|
||||
],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'path',
|
||||
description: 'Show config file location',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'Show all current settings',
|
||||
flags: [
|
||||
COMMON_FLAGS.json,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get',
|
||||
description: 'Get a specific value (raw, scriptable)',
|
||||
acceptsPositional: true,
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'set',
|
||||
description: 'Set a value (auto-coerce types)',
|
||||
acceptsPositional: true,
|
||||
flags: [
|
||||
{
|
||||
name: 'string',
|
||||
description: 'Force value to be stored as string',
|
||||
},
|
||||
{
|
||||
name: 'allow-unknown',
|
||||
description: 'Allow setting unknown keys',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'unset',
|
||||
description: 'Remove a key (revert to default)',
|
||||
acceptsPositional: true,
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'reset',
|
||||
description: 'Reset configuration to defaults',
|
||||
flags: [
|
||||
{
|
||||
name: 'all',
|
||||
description: 'Reset all configuration (required)',
|
||||
},
|
||||
{
|
||||
name: 'yes',
|
||||
short: 'y',
|
||||
description: 'Skip confirmation prompts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
description: 'Open config in $EDITOR',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schema for global OpenSpec configuration.
|
||||
* Uses passthrough() to preserve unknown fields for forward compatibility.
|
||||
*/
|
||||
export const GlobalConfigSchema = z
|
||||
.object({
|
||||
featureFlags: z
|
||||
.record(z.string(), z.boolean())
|
||||
.optional()
|
||||
.default({}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
|
||||
|
||||
/**
|
||||
* Default configuration values.
|
||||
*/
|
||||
export const DEFAULT_CONFIG: GlobalConfigType = {
|
||||
featureFlags: {},
|
||||
};
|
||||
|
||||
const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
||||
|
||||
/**
|
||||
* Validate a config key path for CLI set operations.
|
||||
* Unknown top-level keys are rejected unless explicitly allowed by the caller.
|
||||
*/
|
||||
export function validateConfigKeyPath(path: string): { valid: boolean; reason?: string } {
|
||||
const rawKeys = path.split('.');
|
||||
|
||||
if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) {
|
||||
return { valid: false, reason: 'Key path must not be empty' };
|
||||
}
|
||||
|
||||
const rootKey = rawKeys[0];
|
||||
if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) {
|
||||
return { valid: false, reason: `Unknown top-level key "${rootKey}"` };
|
||||
}
|
||||
|
||||
if (rootKey === 'featureFlags') {
|
||||
if (rawKeys.length > 2) {
|
||||
return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
if (rawKeys.length > 1) {
|
||||
return { valid: false, reason: `"${rootKey}" does not support nested keys` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot notation.
|
||||
*
|
||||
* @param obj - The object to access
|
||||
* @param path - Dot-separated path (e.g., "featureFlags.someFlag")
|
||||
* @returns The value at the path, or undefined if not found
|
||||
*/
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a nested value in an object using dot notation.
|
||||
* Creates intermediate objects as needed.
|
||||
*
|
||||
* @param obj - The object to modify (mutated in place)
|
||||
* @param path - Dot-separated path (e.g., "featureFlags.someFlag")
|
||||
* @param value - The value to set
|
||||
*/
|
||||
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const keys = path.split('.');
|
||||
let current: Record<string, unknown> = obj;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const lastKey = keys[keys.length - 1];
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a nested value from an object using dot notation.
|
||||
*
|
||||
* @param obj - The object to modify (mutated in place)
|
||||
* @param path - Dot-separated path (e.g., "featureFlags.someFlag")
|
||||
* @returns true if the key existed and was deleted, false otherwise
|
||||
*/
|
||||
export function deleteNestedValue(obj: Record<string, unknown>, path: string): boolean {
|
||||
const keys = path.split('.');
|
||||
let current: Record<string, unknown> = obj;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
|
||||
return false;
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey in current) {
|
||||
delete current[lastKey];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a string value to its appropriate type.
|
||||
* - "true" / "false" -> boolean
|
||||
* - Numeric strings -> number
|
||||
* - Everything else -> string
|
||||
*
|
||||
* @param value - The string value to coerce
|
||||
* @param forceString - If true, always return the value as a string
|
||||
* @returns The coerced value
|
||||
*/
|
||||
export function coerceValue(value: string, forceString: boolean = false): string | number | boolean {
|
||||
if (forceString) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Boolean coercion
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Number coercion - must be a valid finite number
|
||||
const num = Number(value);
|
||||
if (!isNaN(num) && isFinite(num) && value.trim() !== '') {
|
||||
return num;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for YAML-like display.
|
||||
*
|
||||
* @param value - The value to format
|
||||
* @param indent - Current indentation level
|
||||
* @returns Formatted string
|
||||
*/
|
||||
export function formatValueYaml(value: unknown, indent: number = 0): string {
|
||||
const indentStr = ' '.repeat(indent);
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\n');
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return '{}';
|
||||
}
|
||||
return entries
|
||||
.map(([key, val]) => {
|
||||
const formattedVal = formatValueYaml(val, indent + 1);
|
||||
if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) {
|
||||
return `${indentStr}${key}:\n${formattedVal}`;
|
||||
}
|
||||
return `${indentStr}${key}: ${formattedVal}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a configuration object against the schema.
|
||||
*
|
||||
* @param config - The configuration to validate
|
||||
* @returns Validation result with success status and optional error message
|
||||
*/
|
||||
export function validateConfig(config: unknown): { success: boolean; error?: string } {
|
||||
try {
|
||||
GlobalConfigSchema.parse(config);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const zodError = error as z.ZodError;
|
||||
const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
|
||||
return { success: false, error: messages.join('; ') };
|
||||
}
|
||||
return { success: false, error: 'Unknown validation error' };
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
||||
import { ChangeParser } from '../parsers/change-parser.js';
|
||||
import { Spec, Change } from '../schemas/index.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
export class JsonConverter {
|
||||
convertSpecToJson(filePath: string): string {
|
||||
@@ -43,7 +44,7 @@ export class JsonConverter {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as os from 'node:os';
|
||||
// Constants
|
||||
export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
|
||||
export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
|
||||
export const GLOBAL_DATA_DIR_NAME = 'openspec';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface GlobalConfig {
|
||||
@@ -45,6 +46,37 @@ export function getGlobalConfigDir(): string {
|
||||
return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the global data directory path following XDG Base Directory Specification.
|
||||
* Used for user data like schema overrides.
|
||||
*
|
||||
* - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set
|
||||
* - Unix/macOS fallback: ~/.local/share/openspec/
|
||||
* - Windows fallback: %LOCALAPPDATA%/openspec/
|
||||
*/
|
||||
export function getGlobalDataDir(): string {
|
||||
// XDG_DATA_HOME takes precedence on all platforms when explicitly set
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME;
|
||||
if (xdgDataHome) {
|
||||
return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Windows: use %LOCALAPPDATA%
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (localAppData) {
|
||||
return path.join(localAppData, GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
// Fallback for Windows if LOCALAPPDATA is not set
|
||||
return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
// Unix/macOS fallback: ~/.local/share
|
||||
return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the global config file.
|
||||
*/
|
||||
|
||||
+3
-1
@@ -2,9 +2,11 @@
|
||||
export {
|
||||
GLOBAL_CONFIG_DIR_NAME,
|
||||
GLOBAL_CONFIG_FILE_NAME,
|
||||
GLOBAL_DATA_DIR_NAME,
|
||||
type GlobalConfig,
|
||||
getGlobalConfigDir,
|
||||
getGlobalConfigPath,
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig
|
||||
saveGlobalConfig,
|
||||
getGlobalDataDir
|
||||
} from './global-config.js';
|
||||
+1
-1
@@ -107,7 +107,7 @@ export class UpdateCommand {
|
||||
|
||||
if (updatedSlashFiles.length > 0) {
|
||||
// Normalize to forward slashes for cross-platform log consistency
|
||||
const normalized = updatedSlashFiles.map((p) => p.replace(/\\/g, '/'));
|
||||
const normalized = updatedSlashFiles.map((p) => FileSystemUtils.toPosixPath(p));
|
||||
summaryParts.push(`Updated slash commands: ${normalized.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';
|
||||
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
||||
import { ChangeParser } from '../parsers/change-parser.js';
|
||||
import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';
|
||||
import {
|
||||
import {
|
||||
MIN_PURPOSE_LENGTH,
|
||||
MAX_REQUIREMENT_TEXT_LENGTH,
|
||||
VALIDATION_MESSAGES
|
||||
VALIDATION_MESSAGES
|
||||
} from './constants.js';
|
||||
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
export class Validator {
|
||||
private strictMode: boolean;
|
||||
@@ -359,7 +360,7 @@ export class Validator {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
// Look for the directory name after 'specs' or 'changes'
|
||||
|
||||
+53
-23
@@ -23,16 +23,26 @@ export class ViewCommand {
|
||||
// Display summary metrics
|
||||
this.displaySummary(changesData, specsData);
|
||||
|
||||
// Display draft changes
|
||||
if (changesData.draft.length > 0) {
|
||||
console.log(chalk.bold.gray('\nDraft Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.draft.forEach((change) => {
|
||||
console.log(` ${chalk.gray('○')} ${change.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Display active changes
|
||||
if (changesData.active.length > 0) {
|
||||
console.log(chalk.bold.cyan('\nActive Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.active.forEach(change => {
|
||||
changesData.active.forEach((change) => {
|
||||
const progressBar = this.createProgressBar(change.progress.completed, change.progress.total);
|
||||
const percentage = change.progress.total > 0
|
||||
? Math.round((change.progress.completed / change.progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
const percentage =
|
||||
change.progress.total > 0
|
||||
? Math.round((change.progress.completed / change.progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
console.log(
|
||||
` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}`
|
||||
);
|
||||
@@ -43,7 +53,7 @@ export class ViewCommand {
|
||||
if (changesData.completed.length > 0) {
|
||||
console.log(chalk.bold.green('\nCompleted Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.completed.forEach(change => {
|
||||
changesData.completed.forEach((change) => {
|
||||
console.log(` ${chalk.green('✓')} ${change.name}`);
|
||||
});
|
||||
}
|
||||
@@ -69,33 +79,43 @@ export class ViewCommand {
|
||||
}
|
||||
|
||||
private async getChangesData(openspecDir: string): Promise<{
|
||||
draft: Array<{ name: string }>;
|
||||
active: Array<{ name: string; progress: { total: number; completed: number } }>;
|
||||
completed: Array<{ name: string }>;
|
||||
}> {
|
||||
const changesDir = path.join(openspecDir, 'changes');
|
||||
|
||||
|
||||
if (!fs.existsSync(changesDir)) {
|
||||
return { active: [], completed: [] };
|
||||
return { draft: [], active: [], completed: [] };
|
||||
}
|
||||
|
||||
const draft: Array<{ name: string }> = [];
|
||||
const active: Array<{ name: string; progress: { total: number; completed: number } }> = [];
|
||||
const completed: Array<{ name: string }> = [];
|
||||
|
||||
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
|
||||
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== 'archive') {
|
||||
const progress = await getTaskProgressForChange(changesDir, entry.name);
|
||||
|
||||
if (progress.total === 0 || progress.completed === progress.total) {
|
||||
|
||||
if (progress.total === 0) {
|
||||
// No tasks defined yet - still in planning/draft phase
|
||||
draft.push({ name: entry.name });
|
||||
} else if (progress.completed === progress.total) {
|
||||
// All tasks complete
|
||||
completed.push({ name: entry.name });
|
||||
} else {
|
||||
// Has tasks but not all complete
|
||||
active.push({ name: entry.name, progress });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort active changes by completion percentage (ascending) and then by name for deterministic ordering
|
||||
// Sort all categories by name for deterministic ordering
|
||||
draft.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Sort active changes by completion percentage (ascending) and then by name
|
||||
active.sort((a, b) => {
|
||||
const percentageA = a.progress.total > 0 ? a.progress.completed / a.progress.total : 0;
|
||||
const percentageB = b.progress.total > 0 ? b.progress.completed / b.progress.total : 0;
|
||||
@@ -106,7 +126,7 @@ export class ViewCommand {
|
||||
});
|
||||
completed.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { active, completed };
|
||||
return { draft, active, completed };
|
||||
}
|
||||
|
||||
private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
|
||||
@@ -142,35 +162,45 @@ export class ViewCommand {
|
||||
}
|
||||
|
||||
private displaySummary(
|
||||
changesData: { active: any[]; completed: any[] },
|
||||
changesData: { draft: any[]; active: any[]; completed: any[] },
|
||||
specsData: any[]
|
||||
): void {
|
||||
const totalChanges = changesData.active.length + changesData.completed.length;
|
||||
const totalChanges =
|
||||
changesData.draft.length + changesData.active.length + changesData.completed.length;
|
||||
const totalSpecs = specsData.length;
|
||||
const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0);
|
||||
|
||||
|
||||
// Calculate total task progress
|
||||
let totalTasks = 0;
|
||||
let completedTasks = 0;
|
||||
|
||||
changesData.active.forEach(change => {
|
||||
|
||||
changesData.active.forEach((change) => {
|
||||
totalTasks += change.progress.total;
|
||||
completedTasks += change.progress.completed;
|
||||
});
|
||||
|
||||
|
||||
changesData.completed.forEach(() => {
|
||||
// Completed changes count as 100% done (we don't know exact task count)
|
||||
// This is a simplification
|
||||
});
|
||||
|
||||
console.log(chalk.bold('Summary:'));
|
||||
console.log(` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`);
|
||||
console.log(` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`);
|
||||
console.log(
|
||||
` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`
|
||||
);
|
||||
if (changesData.draft.length > 0) {
|
||||
console.log(` ${chalk.gray('●')} Draft Changes: ${chalk.bold(changesData.draft.length)}`);
|
||||
}
|
||||
console.log(
|
||||
` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`
|
||||
);
|
||||
console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
|
||||
|
||||
|
||||
if (totalTasks > 0) {
|
||||
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
|
||||
console.log(` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`);
|
||||
console.log(
|
||||
` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import path from 'path';
|
||||
import { FileSystemUtils } from './file-system.js';
|
||||
|
||||
/**
|
||||
* Result of validating a change name.
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a change name follows kebab-case conventions.
|
||||
*
|
||||
* Valid names:
|
||||
* - Start with a lowercase letter
|
||||
* - Contain only lowercase letters, numbers, and hyphens
|
||||
* - Do not start or end with a hyphen
|
||||
* - Do not contain consecutive hyphens
|
||||
*
|
||||
* @param name - The change name to validate
|
||||
* @returns Validation result with `valid: true` or `valid: false` with an error message
|
||||
*
|
||||
* @example
|
||||
* validateChangeName('add-auth') // { valid: true }
|
||||
* validateChangeName('Add-Auth') // { valid: false, error: '...' }
|
||||
*/
|
||||
export function validateChangeName(name: string): ValidationResult {
|
||||
// Pattern: starts with lowercase letter, followed by lowercase letters/numbers,
|
||||
// optionally followed by hyphen + lowercase letters/numbers (repeatable)
|
||||
const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
|
||||
if (!name) {
|
||||
return { valid: false, error: 'Change name cannot be empty' };
|
||||
}
|
||||
|
||||
if (!kebabCasePattern.test(name)) {
|
||||
// Provide specific error messages for common mistakes
|
||||
if (/[A-Z]/.test(name)) {
|
||||
return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' };
|
||||
}
|
||||
if (/_/.test(name)) {
|
||||
return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' };
|
||||
}
|
||||
if (name.startsWith('-')) {
|
||||
return { valid: false, error: 'Change name cannot start with a hyphen' };
|
||||
}
|
||||
if (name.endsWith('-')) {
|
||||
return { valid: false, error: 'Change name cannot end with a hyphen' };
|
||||
}
|
||||
if (/--/.test(name)) {
|
||||
return { valid: false, error: 'Change name cannot contain consecutive hyphens' };
|
||||
}
|
||||
if (/[^a-z0-9-]/.test(name)) {
|
||||
return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };
|
||||
}
|
||||
if (/^[0-9]/.test(name)) {
|
||||
return { valid: false, error: 'Change name must start with a letter' };
|
||||
}
|
||||
|
||||
return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new change directory.
|
||||
*
|
||||
* @param projectRoot - The root directory of the project (where `openspec/` lives)
|
||||
* @param name - The change name (must be valid kebab-case)
|
||||
* @throws Error if the change name is invalid
|
||||
* @throws Error if the change directory already exists
|
||||
*
|
||||
* @example
|
||||
* // Creates openspec/changes/add-auth/
|
||||
* await createChange('/path/to/project', 'add-auth')
|
||||
*/
|
||||
export async function createChange(
|
||||
projectRoot: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
// Validate the name first
|
||||
const validation = validateChangeName(name);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
// Build the change directory path
|
||||
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
|
||||
|
||||
// Check if change already exists
|
||||
if (await FileSystemUtils.directoryExists(changeDir)) {
|
||||
throw new Error(`Change '${name}' already exists at ${changeDir}`);
|
||||
}
|
||||
|
||||
// Create the directory (including parent directories if needed)
|
||||
await FileSystemUtils.createDirectory(changeDir);
|
||||
}
|
||||
@@ -42,6 +42,14 @@ function findMarkerIndex(
|
||||
}
|
||||
|
||||
export class FileSystemUtils {
|
||||
/**
|
||||
* Converts a path to use forward slashes (POSIX style).
|
||||
* Essential for cross-platform compatibility with glob libraries like fast-glob.
|
||||
*/
|
||||
static toPosixPath(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
private static isWindowsBasePath(basePath: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,2 +1,3 @@
|
||||
// Shared utilities will be implemented here
|
||||
export {};
|
||||
// Shared utilities
|
||||
export { validateChangeName, createChange } from './change-utils.js';
|
||||
export type { ValidationResult } from './change-utils.js';
|
||||
@@ -1,7 +1,29 @@
|
||||
export function isInteractive(noInteractiveFlag?: boolean): boolean {
|
||||
if (noInteractiveFlag) return false;
|
||||
export type InteractiveOptions = {
|
||||
/**
|
||||
* Explicit "disable prompts" flag passed by internal callers.
|
||||
*/
|
||||
noInteractive?: boolean;
|
||||
/**
|
||||
* Commander-style negated option: `--no-interactive` sets this to false.
|
||||
*/
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves whether non-interactive mode is requested.
|
||||
* Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.
|
||||
* Use this helper instead of manually checking options.noInteractive to avoid bugs.
|
||||
*/
|
||||
export function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return value?.noInteractive === true || value?.interactive === false;
|
||||
}
|
||||
|
||||
export function isInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
if (resolveNoInteractive(value)) return false;
|
||||
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
|
||||
// Respect the standard CI environment variable (set by GitHub Actions, GitLab CI, Travis, etc.)
|
||||
if ('CI' in process.env) return false;
|
||||
return !!process.stdin.isTTY;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
|
||||
describe('artifact-workflow CLI commands', () => {
|
||||
let tempDir: string;
|
||||
let changesDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));
|
||||
changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets combined output from CLI result (ora outputs to stdout).
|
||||
*/
|
||||
function getOutput(result: { stdout: string; stderr: string }): string {
|
||||
return result.stdout + result.stderr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test change with the specified artifacts completed.
|
||||
* Note: An "active" change requires at least a proposal.md file to be detected.
|
||||
* If no artifacts are specified, we create an empty proposal to make it detectable.
|
||||
*/
|
||||
async function createTestChange(
|
||||
changeName: string,
|
||||
artifacts: ('proposal' | 'design' | 'specs' | 'tasks')[] = []
|
||||
): Promise<string> {
|
||||
const changeDir = path.join(changesDir, changeName);
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
// Always create proposal.md for the change to be detected as active
|
||||
// Content varies based on whether 'proposal' is in artifacts list
|
||||
const proposalContent = artifacts.includes('proposal')
|
||||
? '## Why\nTest proposal content that is long enough.\n\n## What Changes\n- **test:** Something'
|
||||
: '## Why\nMinimal proposal.\n\n## What Changes\n- **test:** Placeholder';
|
||||
await fs.writeFile(path.join(changeDir, 'proposal.md'), proposalContent);
|
||||
|
||||
if (artifacts.includes('design')) {
|
||||
await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\n\nTechnical design.');
|
||||
}
|
||||
|
||||
if (artifacts.includes('specs')) {
|
||||
// specs artifact uses glob pattern "specs/*.md" - files directly in specs/ directory
|
||||
const specsDir = path.join(changeDir, 'specs');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\nTest spec.');
|
||||
}
|
||||
|
||||
if (artifacts.includes('tasks')) {
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '## Tasks\n- [ ] Task 1');
|
||||
}
|
||||
|
||||
return changeDir;
|
||||
}
|
||||
|
||||
describe('status command', () => {
|
||||
it('shows status for scaffolded change without proposal.md', async () => {
|
||||
// Create empty change directory (no proposal.md)
|
||||
const changeDir = path.join(changesDir, 'scaffolded-change');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
const result = await runCLI(['status', '--change', 'scaffolded-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('scaffolded-change');
|
||||
expect(result.stdout).toContain('0/4 artifacts complete');
|
||||
});
|
||||
|
||||
it('shows status for a change with proposal only', async () => {
|
||||
// createTestChange always creates proposal.md, so this has 1 artifact complete
|
||||
await createTestChange('minimal-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'minimal-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('minimal-change');
|
||||
expect(result.stdout).toContain('spec-driven');
|
||||
expect(result.stdout).toContain('1/4 artifacts complete');
|
||||
});
|
||||
|
||||
it('shows status for a change with proposal and design', async () => {
|
||||
await createTestChange('partial-change', ['proposal', 'design']);
|
||||
|
||||
const result = await runCLI(['status', '--change', 'partial-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('2/4 artifacts complete');
|
||||
expect(result.stdout).toContain('[x]');
|
||||
});
|
||||
|
||||
it('outputs JSON when --json flag is used', async () => {
|
||||
await createTestChange('json-change', ['proposal', 'design']);
|
||||
|
||||
const result = await runCLI(['status', '--change', 'json-change', '--json'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changeName).toBe('json-change');
|
||||
expect(json.schemaName).toBe('spec-driven');
|
||||
expect(json.isComplete).toBe(false);
|
||||
expect(Array.isArray(json.artifacts)).toBe(true);
|
||||
expect(json.artifacts).toHaveLength(4);
|
||||
|
||||
const proposalArtifact = json.artifacts.find((a: any) => a.id === 'proposal');
|
||||
expect(proposalArtifact.status).toBe('done');
|
||||
});
|
||||
|
||||
it('shows complete status when all artifacts are done', async () => {
|
||||
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
|
||||
|
||||
const result = await runCLI(['status', '--change', 'complete-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('4/4 artifacts complete');
|
||||
expect(result.stdout).toContain('All artifacts complete!');
|
||||
});
|
||||
|
||||
it('errors when --change is missing and lists available changes', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(['status'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Missing required option --change');
|
||||
expect(output).toContain('some-change');
|
||||
});
|
||||
|
||||
it('errors for unknown change name and lists available changes', async () => {
|
||||
await createTestChange('existing-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'nonexistent'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Change 'nonexistent' not found");
|
||||
expect(output).toContain('existing-change');
|
||||
});
|
||||
|
||||
it('supports --schema option', async () => {
|
||||
await createTestChange('tdd-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'tdd-change', '--schema', 'tdd'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('tdd');
|
||||
});
|
||||
|
||||
it('errors for unknown schema', async () => {
|
||||
await createTestChange('test-change');
|
||||
|
||||
const result = await runCLI(['status', '--change', 'test-change', '--schema', 'unknown'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Schema 'unknown' not found");
|
||||
});
|
||||
|
||||
it('rejects path traversal in change name', async () => {
|
||||
const result = await runCLI(['status', '--change', '../foo'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Invalid change name');
|
||||
});
|
||||
|
||||
it('rejects absolute path in change name', async () => {
|
||||
const result = await runCLI(['status', '--change', '/etc/passwd'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Invalid change name');
|
||||
});
|
||||
|
||||
it('rejects slashes in change name', async () => {
|
||||
const result = await runCLI(['status', '--change', 'foo/bar'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Invalid change name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('next command', () => {
|
||||
it('shows proposal as next for scaffolded change', async () => {
|
||||
// Create empty change directory (no proposal.md)
|
||||
const changeDir = path.join(changesDir, 'scaffolded-change');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
const result = await runCLI(['next', '--change', 'scaffolded-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifacts ready to create');
|
||||
expect(result.stdout).toContain('proposal');
|
||||
});
|
||||
|
||||
it('shows design and specs as next when proposal exists', async () => {
|
||||
// createTestChange always creates proposal.md, so design and specs are ready
|
||||
await createTestChange('minimal-change');
|
||||
|
||||
const result = await runCLI(['next', '--change', 'minimal-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifacts ready to create');
|
||||
expect(result.stdout).toContain('design');
|
||||
expect(result.stdout).toContain('specs');
|
||||
});
|
||||
|
||||
it('shows tasks as next after proposal, design, and specs', async () => {
|
||||
await createTestChange('after-specs', ['proposal', 'design', 'specs']);
|
||||
|
||||
const result = await runCLI(['next', '--change', 'after-specs'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('tasks');
|
||||
});
|
||||
|
||||
it('shows complete message when all done', async () => {
|
||||
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
|
||||
|
||||
const result = await runCLI(['next', '--change', 'complete-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('All artifacts are complete!');
|
||||
});
|
||||
|
||||
it('outputs JSON array of ready artifacts', async () => {
|
||||
await createTestChange('json-next', ['proposal']);
|
||||
|
||||
const result = await runCLI(['next', '--change', 'json-next', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(json)).toBe(true);
|
||||
expect(json).toContain('design');
|
||||
expect(json).toContain('specs');
|
||||
});
|
||||
|
||||
it('errors when --change is missing and lists available changes', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(['next'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Missing required option --change');
|
||||
expect(output).toContain('some-change');
|
||||
});
|
||||
});
|
||||
|
||||
describe('instructions command', () => {
|
||||
it('shows instructions for proposal on scaffolded change', async () => {
|
||||
// Create empty change directory (no proposal.md)
|
||||
const changeDir = path.join(changesDir, 'scaffolded-change');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
const result = await runCLI(['instructions', 'proposal', '--change', 'scaffolded-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifact: proposal');
|
||||
expect(result.stdout).toContain('proposal.md');
|
||||
expect(result.stdout).toContain('Template:');
|
||||
});
|
||||
|
||||
it('shows instructions for design artifact', async () => {
|
||||
await createTestChange('instr-change');
|
||||
|
||||
const result = await runCLI(['instructions', 'design', '--change', 'instr-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Artifact: design');
|
||||
expect(result.stdout).toContain('design.md');
|
||||
expect(result.stdout).toContain('Template:');
|
||||
});
|
||||
|
||||
it('shows blocked warning for artifact with unmet dependencies', async () => {
|
||||
// tasks depends on design and specs, which are not done yet
|
||||
await createTestChange('blocked-change');
|
||||
|
||||
const result = await runCLI(['instructions', 'tasks', '--change', 'blocked-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Warning: This artifact has unmet dependencies');
|
||||
expect(result.stdout).toContain('[missing]');
|
||||
});
|
||||
|
||||
it('outputs JSON for instructions', async () => {
|
||||
await createTestChange('json-instr', ['proposal']);
|
||||
|
||||
const result = await runCLI(['instructions', 'design', '--change', 'json-instr', '--json'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.artifactId).toBe('design');
|
||||
expect(json.outputPath).toContain('design.md');
|
||||
expect(typeof json.template).toBe('string');
|
||||
expect(Array.isArray(json.dependencies)).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when artifact argument is missing', async () => {
|
||||
await createTestChange('test-change');
|
||||
|
||||
const result = await runCLI(['instructions', '--change', 'test-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Missing required argument <artifact>');
|
||||
expect(output).toContain('Valid artifacts');
|
||||
});
|
||||
|
||||
it('errors for unknown artifact', async () => {
|
||||
await createTestChange('test-change');
|
||||
|
||||
const result = await runCLI(['instructions', 'unknown-artifact', '--change', 'test-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Artifact 'unknown-artifact' not found");
|
||||
expect(output).toContain('Valid artifacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('templates command', () => {
|
||||
it('shows template paths for default schema', async () => {
|
||||
const result = await runCLI(['templates'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Schema: spec-driven');
|
||||
expect(result.stdout).toContain('proposal:');
|
||||
expect(result.stdout).toContain('design:');
|
||||
expect(result.stdout).toContain('specs:');
|
||||
expect(result.stdout).toContain('tasks:');
|
||||
});
|
||||
|
||||
it('shows template paths for custom schema', async () => {
|
||||
const result = await runCLI(['templates', '--schema', 'tdd'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Schema: tdd');
|
||||
expect(result.stdout).toContain('spec:');
|
||||
expect(result.stdout).toContain('tests:');
|
||||
});
|
||||
|
||||
it('outputs JSON mapping of templates', async () => {
|
||||
const result = await runCLI(['templates', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.proposal).toBeDefined();
|
||||
expect(json.proposal.path).toContain('proposal.md');
|
||||
expect(json.proposal.source).toBe('package');
|
||||
});
|
||||
|
||||
it('errors for unknown schema', async () => {
|
||||
const result = await runCLI(['templates', '--schema', 'nonexistent'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Schema 'nonexistent' not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe('new change command', () => {
|
||||
it('creates a new change directory', async () => {
|
||||
const result = await runCLI(['new', 'change', 'my-new-feature'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Created change 'my-new-feature'");
|
||||
|
||||
const changeDir = path.join(changesDir, 'my-new-feature');
|
||||
const stat = await fs.stat(changeDir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('creates README.md when --description is provided', async () => {
|
||||
const result = await runCLI(
|
||||
['new', 'change', 'described-feature', '--description', 'This is a test feature'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const readmePath = path.join(changesDir, 'described-feature', 'README.md');
|
||||
const content = await fs.readFile(readmePath, 'utf-8');
|
||||
expect(content).toContain('described-feature');
|
||||
expect(content).toContain('This is a test feature');
|
||||
});
|
||||
|
||||
it('errors for invalid change name with spaces', async () => {
|
||||
const result = await runCLI(['new', 'change', 'invalid name'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('Error');
|
||||
});
|
||||
|
||||
it('errors for duplicate change name', async () => {
|
||||
await createTestChange('existing-change');
|
||||
|
||||
const result = await runCLI(['new', 'change', 'existing-change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('exists');
|
||||
});
|
||||
|
||||
it('errors when name argument is missing', async () => {
|
||||
const result = await runCLI(['new', 'change'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('help text', () => {
|
||||
it('marks status command as experimental in help', async () => {
|
||||
const result = await runCLI(['status', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks next command as experimental in help', async () => {
|
||||
const result = await runCLI(['next', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks instructions command as experimental in help', async () => {
|
||||
const result = await runCLI(['instructions', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks templates command as experimental in help', async () => {
|
||||
const result = await runCLI(['templates', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
|
||||
it('marks new command as experimental in help', async () => {
|
||||
const result = await runCLI(['new', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
describe('config command integration', () => {
|
||||
// These tests use real file system operations with XDG_CONFIG_HOME override
|
||||
let tempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Save original env and set XDG_CONFIG_HOME
|
||||
originalEnv = { ...process.env };
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
|
||||
// Spy on console.error
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
process.env = originalEnv;
|
||||
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
// Restore spies
|
||||
consoleErrorSpy.mockRestore();
|
||||
|
||||
// Reset module cache to pick up new XDG_CONFIG_HOME
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should use XDG_CONFIG_HOME for config path', async () => {
|
||||
const { getGlobalConfigPath } = await import('../../src/core/global-config.js');
|
||||
const configPath = getGlobalConfigPath();
|
||||
expect(configPath).toBe(path.join(tempDir, 'openspec', 'config.json'));
|
||||
});
|
||||
|
||||
it('should save and load config correctly', async () => {
|
||||
const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js');
|
||||
|
||||
saveGlobalConfig({ featureFlags: { test: true } });
|
||||
const config = getGlobalConfig();
|
||||
expect(config.featureFlags).toEqual({ test: true });
|
||||
});
|
||||
|
||||
it('should return defaults when config file does not exist', async () => {
|
||||
const { getGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js');
|
||||
|
||||
const configPath = getGlobalConfigPath();
|
||||
// Make sure config doesn't exist
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
|
||||
const config = getGlobalConfig();
|
||||
expect(config.featureFlags).toEqual({});
|
||||
});
|
||||
|
||||
it('should preserve unknown fields', async () => {
|
||||
const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js');
|
||||
|
||||
const configDir = getGlobalConfigDir();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({
|
||||
featureFlags: {},
|
||||
customField: 'preserved',
|
||||
}));
|
||||
|
||||
const config = getGlobalConfig();
|
||||
expect((config as Record<string, unknown>).customField).toBe('preserved');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON gracefully', async () => {
|
||||
const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js');
|
||||
|
||||
const configDir = getGlobalConfigDir();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.json'), '{ invalid json }');
|
||||
|
||||
const config = getGlobalConfig();
|
||||
// Should return defaults
|
||||
expect(config.featureFlags).toEqual({});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('config command shell completion registry', () => {
|
||||
it('should have config command in registry', async () => {
|
||||
const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');
|
||||
|
||||
const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');
|
||||
expect(configCmd).toBeDefined();
|
||||
expect(configCmd?.description).toBe('View and modify global OpenSpec configuration');
|
||||
});
|
||||
|
||||
it('should have all config subcommands in registry', async () => {
|
||||
const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');
|
||||
|
||||
const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');
|
||||
const subcommandNames = configCmd?.subcommands?.map((s) => s.name) ?? [];
|
||||
|
||||
expect(subcommandNames).toContain('path');
|
||||
expect(subcommandNames).toContain('list');
|
||||
expect(subcommandNames).toContain('get');
|
||||
expect(subcommandNames).toContain('set');
|
||||
expect(subcommandNames).toContain('unset');
|
||||
expect(subcommandNames).toContain('reset');
|
||||
expect(subcommandNames).toContain('edit');
|
||||
});
|
||||
|
||||
it('should have --json flag on list subcommand', async () => {
|
||||
const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');
|
||||
|
||||
const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');
|
||||
const listCmd = configCmd?.subcommands?.find((s) => s.name === 'list');
|
||||
const flagNames = listCmd?.flags?.map((f) => f.name) ?? [];
|
||||
|
||||
expect(flagNames).toContain('json');
|
||||
});
|
||||
|
||||
it('should have --string flag on set subcommand', async () => {
|
||||
const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');
|
||||
|
||||
const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');
|
||||
const setCmd = configCmd?.subcommands?.find((s) => s.name === 'set');
|
||||
const flagNames = setCmd?.flags?.map((f) => f.name) ?? [];
|
||||
|
||||
expect(flagNames).toContain('string');
|
||||
expect(flagNames).toContain('allow-unknown');
|
||||
});
|
||||
|
||||
it('should have --all and -y flags on reset subcommand', async () => {
|
||||
const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');
|
||||
|
||||
const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');
|
||||
const resetCmd = configCmd?.subcommands?.find((s) => s.name === 'reset');
|
||||
const flagNames = resetCmd?.flags?.map((f) => f.name) ?? [];
|
||||
|
||||
expect(flagNames).toContain('all');
|
||||
expect(flagNames).toContain('yes');
|
||||
});
|
||||
|
||||
it('should have --scope flag on config command', async () => {
|
||||
const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');
|
||||
|
||||
const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');
|
||||
const flagNames = configCmd?.flags?.map((f) => f.name) ?? [];
|
||||
|
||||
expect(flagNames).toContain('scope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config key validation', () => {
|
||||
it('rejects unknown top-level keys', async () => {
|
||||
const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');
|
||||
expect(validateConfigKeyPath('unknownKey').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('allows feature flag keys', async () => {
|
||||
const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');
|
||||
expect(validateConfigKeyPath('featureFlags.someFlag').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects deeply nested feature flag keys', async () => {
|
||||
const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');
|
||||
expect(validateConfigKeyPath('featureFlags.someFlag.extra').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -130,4 +130,18 @@ describe('top-level validate command', () => {
|
||||
const result = await runCLI(['validate', changeId], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('respects --no-interactive flag passed via CLI', async () => {
|
||||
// This test ensures Commander.js --no-interactive flag is correctly parsed
|
||||
// and passed to the validate command. The flag sets options.interactive = false
|
||||
// (not options.noInteractive = true) due to Commander.js convention.
|
||||
const result = await runCLI(['validate', '--specs', '--no-interactive'], {
|
||||
cwd: testDir,
|
||||
// Don't set OPEN_SPEC_INTERACTIVE to ensure we're testing the flag itself
|
||||
env: { ...process.env, OPEN_SPEC_INTERACTIVE: undefined },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Should complete without hanging and without prompts
|
||||
expect(result.stderr).not.toContain('What would you like to validate?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,133 @@ Then expected result happens`;
|
||||
expect(updatedContent).toContain('#### Scenario: Basic test');
|
||||
});
|
||||
|
||||
it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => {
|
||||
const changeName = 'new-spec-with-removed';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'gift-card');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create delta spec with both ADDED and REMOVED requirements
|
||||
// This simulates refactoring where old fields are removed and new ones are added
|
||||
const specContent = `# Gift Card - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Logo and Background Color
|
||||
The system SHALL support logo and backgroundColor fields for gift cards.
|
||||
|
||||
#### Scenario: Display gift card with logo
|
||||
- **WHEN** a gift card is displayed
|
||||
- **THEN** it shows the logo and backgroundColor
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Image Field
|
||||
### Requirement: Thumbnail Field`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive - should succeed with warning about REMOVED requirements
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
// Verify warning was logged about REMOVED requirements being ignored
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).')
|
||||
);
|
||||
|
||||
// Verify spec was created with only ADDED requirements
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md');
|
||||
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
|
||||
expect(updatedContent).toContain('# gift-card Specification');
|
||||
expect(updatedContent).toContain('### Requirement: Logo and Background Color');
|
||||
expect(updatedContent).toContain('#### Scenario: Display gift card with logo');
|
||||
// REMOVED requirements should not be in the final spec
|
||||
expect(updatedContent).not.toContain('### Requirement: Image Field');
|
||||
expect(updatedContent).not.toContain('### Requirement: Thumbnail Field');
|
||||
|
||||
// Verify change was archived successfully
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.length).toBeGreaterThan(0);
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(true);
|
||||
});
|
||||
|
||||
it('should still error on MODIFIED when creating new spec file', async () => {
|
||||
const changeName = 'new-spec-with-modified';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'new-capability');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create delta spec with MODIFIED requirement (should fail for new spec)
|
||||
const specContent = `# New Capability - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
New feature description.
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
Modified content.`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive - should abort with error message (not throw, but log and return)
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
// Verify error message mentions MODIFIED not allowed for new specs
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('new-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
|
||||
|
||||
// Verify spec was NOT created
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md');
|
||||
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
||||
|
||||
// Verify change was NOT archived
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(false);
|
||||
});
|
||||
|
||||
it('should still error on RENAMED when creating new spec file', async () => {
|
||||
const changeName = 'new-spec-with-renamed';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'another-capability');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create delta spec with RENAMED requirement (should fail for new spec)
|
||||
const specContent = `# Another Capability - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
New feature description.
|
||||
|
||||
## RENAMED Requirements
|
||||
- FROM: \`### Requirement: Old Name\`
|
||||
- TO: \`### Requirement: New Name\``;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive - should abort with error message (not throw, but log and return)
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
// Verify error message mentions RENAMED not allowed for new specs
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('another-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
|
||||
|
||||
// Verify spec was NOT created
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md');
|
||||
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
||||
|
||||
// Verify change was NOT archived
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if change does not exist', async () => {
|
||||
await expect(
|
||||
archiveCommand.execute('non-existent-change', { yes: true })
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
|
||||
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
|
||||
|
||||
describe('artifact-graph/graph', () => {
|
||||
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
|
||||
name: 'test',
|
||||
version: 1,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
describe('fromSchema', () => {
|
||||
it('should create graph from schema object', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getName()).toBe('test');
|
||||
expect(graph.getVersion()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromYamlContent', () => {
|
||||
it('should create graph from YAML string', () => {
|
||||
const yaml = `
|
||||
name: my-workflow
|
||||
version: 2
|
||||
artifacts:
|
||||
- id: doc
|
||||
generates: doc.md
|
||||
description: Documentation
|
||||
template: templates/doc.md
|
||||
`;
|
||||
const graph = ArtifactGraph.fromYamlContent(yaml);
|
||||
|
||||
expect(graph.getName()).toBe('my-workflow');
|
||||
expect(graph.getVersion()).toBe(2);
|
||||
expect(graph.getArtifact('doc')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArtifact', () => {
|
||||
it('should return artifact by ID', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const artifact = graph.getArtifact('proposal');
|
||||
|
||||
expect(artifact).toBeDefined();
|
||||
expect(artifact?.id).toBe('proposal');
|
||||
expect(artifact?.generates).toBe('proposal.md');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent ID', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getArtifact('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllArtifacts', () => {
|
||||
it('should return all artifacts', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const artifacts = graph.getAllArtifacts();
|
||||
|
||||
expect(artifacts).toHaveLength(3);
|
||||
expect(artifacts.map(a => a.id).sort()).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildOrder', () => {
|
||||
it('should return correct order for linear chain A → B → C', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['B'] },
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order = graph.getBuildOrder();
|
||||
|
||||
expect(order).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('should handle diamond dependency correctly', () => {
|
||||
// A → B, A → C, B → D, C → D
|
||||
const schema = createSchema([
|
||||
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order = graph.getBuildOrder();
|
||||
|
||||
// A must come before B and C; D must come last
|
||||
expect(order.indexOf('A')).toBeLessThan(order.indexOf('B'));
|
||||
expect(order.indexOf('A')).toBeLessThan(order.indexOf('C'));
|
||||
expect(order.indexOf('B')).toBeLessThan(order.indexOf('D'));
|
||||
expect(order.indexOf('C')).toBeLessThan(order.indexOf('D'));
|
||||
});
|
||||
|
||||
it('should return independent artifacts in stable sorted order', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'Z', generates: 'z.md', description: 'Z', template: 't.md', requires: [] },
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'M', generates: 'm.md', description: 'M', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order = graph.getBuildOrder();
|
||||
|
||||
// All independent, should be sorted alphabetically for stability
|
||||
expect(order).toEqual(['A', 'M', 'Z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextArtifacts', () => {
|
||||
it('should return root artifacts when nothing completed', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const ready = graph.getNextArtifacts(new Set());
|
||||
|
||||
expect(ready.sort()).toEqual(['A', 'C']);
|
||||
});
|
||||
|
||||
it('should include artifact when all deps completed', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const ready = graph.getNextArtifacts(new Set(['A']));
|
||||
|
||||
expect(ready).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('should not include completed artifacts', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const ready = graph.getNextArtifacts(new Set(['A', 'B']));
|
||||
|
||||
expect(ready).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle diamond dependency correctly', () => {
|
||||
// D requires B and C
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
|
||||
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Only A completed - B and C ready, D not
|
||||
expect(graph.getNextArtifacts(new Set(['A'])).sort()).toEqual(['B', 'C']);
|
||||
|
||||
// Only B completed (from deps) - C still needed for D
|
||||
expect(graph.getNextArtifacts(new Set(['A', 'B']))).toEqual(['C']);
|
||||
|
||||
// Both B and C completed - D ready
|
||||
expect(graph.getNextArtifacts(new Set(['A', 'B', 'C']))).toEqual(['D']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isComplete', () => {
|
||||
it('should return true when all artifacts completed', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.isComplete(new Set(['A', 'B']))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when some artifacts incomplete', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.isComplete(new Set(['A']))).toBe(false);
|
||||
expect(graph.isComplete(new Set())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlocked', () => {
|
||||
it('should return empty object when nothing is blocked', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getBlocked(new Set())).toEqual({});
|
||||
});
|
||||
|
||||
it('should return artifact blocked by single dependency', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getBlocked(new Set())).toEqual({ B: ['A'] });
|
||||
});
|
||||
|
||||
it('should return artifact blocked by multiple dependencies', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Neither A nor B completed
|
||||
expect(graph.getBlocked(new Set())).toEqual({ C: ['A', 'B'] });
|
||||
});
|
||||
|
||||
it('should only list unmet dependencies', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
|
||||
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// A completed, B not
|
||||
expect(graph.getBlocked(new Set(['A']))).toEqual({ C: ['B'] });
|
||||
});
|
||||
|
||||
it('should not include completed artifacts', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getBlocked(new Set(['A', 'B']))).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
loadTemplate,
|
||||
loadChangeContext,
|
||||
generateInstructions,
|
||||
formatChangeStatus,
|
||||
TemplateLoadError,
|
||||
} from '../../../src/core/artifact-graph/instruction-loader.js';
|
||||
|
||||
describe('instruction-loader', () => {
|
||||
describe('loadTemplate', () => {
|
||||
it('should load template from schema directory', () => {
|
||||
// Uses built-in spec-driven schema
|
||||
const template = loadTemplate('spec-driven', 'proposal.md');
|
||||
|
||||
expect(template).toContain('## Why');
|
||||
expect(template).toContain('## What Changes');
|
||||
});
|
||||
|
||||
it('should throw TemplateLoadError for non-existent template', () => {
|
||||
expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow(
|
||||
TemplateLoadError
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw TemplateLoadError for non-existent schema', () => {
|
||||
expect(() => loadTemplate('nonexistent-schema', 'proposal.md')).toThrow(
|
||||
TemplateLoadError
|
||||
);
|
||||
});
|
||||
|
||||
it('should include template path in error', () => {
|
||||
try {
|
||||
loadTemplate('spec-driven', 'nonexistent.md');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TemplateLoadError);
|
||||
expect((err as TemplateLoadError).templatePath).toContain('nonexistent.md');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadChangeContext', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load context with default schema', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
|
||||
expect(context.schemaName).toBe('spec-driven');
|
||||
expect(context.changeName).toBe('my-change');
|
||||
expect(context.graph.getName()).toBe('spec-driven');
|
||||
expect(context.completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should load context with custom schema', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change', 'tdd');
|
||||
|
||||
expect(context.schemaName).toBe('tdd');
|
||||
expect(context.graph.getName()).toBe('tdd');
|
||||
});
|
||||
|
||||
it('should detect completed artifacts', () => {
|
||||
// Create change directory with proposal.md
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
|
||||
expect(context.completed.has('proposal')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty completed set for non-existent change directory', () => {
|
||||
const context = loadChangeContext(tempDir, 'nonexistent-change');
|
||||
|
||||
expect(context.completed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateInstructions', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should include artifact metadata', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
expect(instructions.changeName).toBe('my-change');
|
||||
expect(instructions.artifactId).toBe('proposal');
|
||||
expect(instructions.schemaName).toBe('spec-driven');
|
||||
expect(instructions.outputPath).toBe('proposal.md');
|
||||
});
|
||||
|
||||
it('should include template content', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
expect(instructions.template).toContain('## Why');
|
||||
});
|
||||
|
||||
it('should show dependencies with completion status', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'specs');
|
||||
|
||||
expect(instructions.dependencies).toHaveLength(1);
|
||||
expect(instructions.dependencies[0].id).toBe('proposal');
|
||||
expect(instructions.dependencies[0].done).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark completed dependencies as done', () => {
|
||||
// Create proposal
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'specs');
|
||||
|
||||
expect(instructions.dependencies[0].done).toBe(true);
|
||||
});
|
||||
|
||||
it('should list artifacts unlocked by this one', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
// proposal unlocks specs and design
|
||||
expect(instructions.unlocks).toContain('specs');
|
||||
expect(instructions.unlocks).toContain('design');
|
||||
});
|
||||
|
||||
it('should have empty dependencies for root artifact', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const instructions = generateInstructions(context, 'proposal');
|
||||
|
||||
expect(instructions.dependencies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw for non-existent artifact', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
|
||||
expect(() => generateInstructions(context, 'nonexistent')).toThrow(
|
||||
"Artifact 'nonexistent' not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatChangeStatus', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should show all artifacts as ready/blocked when nothing completed', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
expect(status.changeName).toBe('my-change');
|
||||
expect(status.schemaName).toBe('spec-driven');
|
||||
expect(status.isComplete).toBe(false);
|
||||
|
||||
// proposal has no deps, should be ready
|
||||
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
||||
expect(proposal?.status).toBe('ready');
|
||||
|
||||
// specs depends on proposal, should be blocked
|
||||
const specs = status.artifacts.find(a => a.id === 'specs');
|
||||
expect(specs?.status).toBe('blocked');
|
||||
expect(specs?.missingDeps).toContain('proposal');
|
||||
});
|
||||
|
||||
it('should show completed artifacts as done', () => {
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
||||
expect(proposal?.status).toBe('done');
|
||||
|
||||
// specs should now be ready
|
||||
const specs = status.artifacts.find(a => a.id === 'specs');
|
||||
expect(specs?.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should include output paths for each artifact', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
||||
expect(proposal?.outputPath).toBe('proposal.md');
|
||||
|
||||
const specs = status.artifacts.find(a => a.id === 'specs');
|
||||
expect(specs?.outputPath).toBe('specs/**/*.md');
|
||||
});
|
||||
|
||||
it('should report isComplete true when all done', () => {
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
||||
fs.mkdirSync(changeDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true });
|
||||
|
||||
// Create all required files for spec-driven schema
|
||||
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
||||
fs.writeFileSync(path.join(changeDir, 'specs', 'test.md'), '# Spec');
|
||||
fs.writeFileSync(path.join(changeDir, 'design.md'), '# Design');
|
||||
fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks');
|
||||
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
expect(status.isComplete).toBe(true);
|
||||
expect(status.artifacts.every(a => a.status === 'done')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show blocked artifacts with missing dependencies', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
// tasks requires specs and design
|
||||
const tasks = status.artifacts.find(a => a.id === 'tasks');
|
||||
expect(tasks?.status).toBe('blocked');
|
||||
expect(tasks?.missingDeps).toContain('specs');
|
||||
expect(tasks?.missingDeps).toContain('design');
|
||||
});
|
||||
|
||||
it('should sort artifacts in build order', () => {
|
||||
const context = loadChangeContext(tempDir, 'my-change');
|
||||
const status = formatChangeStatus(context);
|
||||
|
||||
const ids = status.artifacts.map(a => a.id);
|
||||
const proposalIdx = ids.indexOf('proposal');
|
||||
const specsIdx = ids.indexOf('specs');
|
||||
const tasksIdx = ids.indexOf('tasks');
|
||||
|
||||
// proposal must come before specs, specs before tasks
|
||||
expect(proposalIdx).toBeLessThan(specsIdx);
|
||||
expect(specsIdx).toBeLessThan(tasksIdx);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
resolveSchema,
|
||||
listSchemas,
|
||||
SchemaLoadError,
|
||||
getSchemaDir,
|
||||
getPackageSchemasDir,
|
||||
getUserSchemasDir,
|
||||
} from '../../../src/core/artifact-graph/resolver.js';
|
||||
|
||||
describe('artifact-graph/resolver', () => {
|
||||
let tempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(os.tmpdir(), `openspec-resolver-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getPackageSchemasDir', () => {
|
||||
it('should return a valid path', () => {
|
||||
const schemasDir = getPackageSchemasDir();
|
||||
expect(typeof schemasDir).toBe('string');
|
||||
expect(schemasDir.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserSchemasDir', () => {
|
||||
it('should use XDG_DATA_HOME when set', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userDir = getUserSchemasDir();
|
||||
expect(userDir).toBe(path.join(tempDir, 'openspec', 'schemas'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSchemaDir', () => {
|
||||
it('should return null for non-existent schema', () => {
|
||||
const dir = getSchemaDir('nonexistent-schema');
|
||||
expect(dir).toBeNull();
|
||||
});
|
||||
|
||||
it('should return package dir for built-in schema', () => {
|
||||
const dir = getSchemaDir('spec-driven');
|
||||
expect(dir).not.toBeNull();
|
||||
expect(dir).toContain('schemas');
|
||||
expect(dir).toContain('spec-driven');
|
||||
});
|
||||
|
||||
it('should prefer user override directory', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(userSchemaDir, 'schema.yaml'),
|
||||
'name: custom\nversion: 1\nartifacts: []'
|
||||
);
|
||||
|
||||
const dir = getSchemaDir('spec-driven');
|
||||
expect(dir).toBe(userSchemaDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSchema', () => {
|
||||
it('should return built-in spec-driven schema', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('spec-driven');
|
||||
expect(schema.version).toBe(1);
|
||||
expect(schema.artifacts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return built-in tdd schema', () => {
|
||||
const schema = resolveSchema('tdd');
|
||||
|
||||
expect(schema.name).toBe('tdd');
|
||||
expect(schema.version).toBe(1);
|
||||
expect(schema.artifacts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should strip .yaml extension from name', () => {
|
||||
const schema1 = resolveSchema('spec-driven');
|
||||
const schema2 = resolveSchema('spec-driven.yaml');
|
||||
|
||||
expect(schema1).toEqual(schema2);
|
||||
});
|
||||
|
||||
it('should strip .yml extension from name', () => {
|
||||
const schema1 = resolveSchema('spec-driven');
|
||||
const schema2 = resolveSchema('spec-driven.yml');
|
||||
|
||||
expect(schema1).toEqual(schema2);
|
||||
});
|
||||
|
||||
it('should prefer user override over built-in', () => {
|
||||
// Set up global data dir
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a custom schema with same name as built-in
|
||||
const customSchema = `
|
||||
name: custom-override
|
||||
version: 99
|
||||
artifacts:
|
||||
- id: custom
|
||||
generates: custom.md
|
||||
description: Custom artifact
|
||||
template: custom.md
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), customSchema);
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('custom-override');
|
||||
expect(schema.version).toBe(99);
|
||||
});
|
||||
|
||||
it('should validate user override and throw on invalid schema', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create an invalid schema (missing required fields)
|
||||
const invalidSchema = `
|
||||
name: invalid
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: broken
|
||||
# missing generates, description, template
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError);
|
||||
});
|
||||
|
||||
it('should include file path in validation error message', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
const invalidSchema = `
|
||||
name: invalid
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: broken
|
||||
`;
|
||||
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
|
||||
fs.writeFileSync(schemaPath, invalidSchema);
|
||||
|
||||
try {
|
||||
resolveSchema('spec-driven');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as SchemaLoadError;
|
||||
expect(error.message).toContain(schemaPath);
|
||||
expect(error.schemaPath).toBe(schemaPath);
|
||||
expect(error.cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect cycles in user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a schema with cyclic dependencies
|
||||
const cyclicSchema = `
|
||||
name: cyclic
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: a
|
||||
generates: a.md
|
||||
description: A
|
||||
template: a.md
|
||||
requires: [b]
|
||||
- id: b
|
||||
generates: b.md
|
||||
description: B
|
||||
template: b.md
|
||||
requires: [a]
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), cyclicSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);
|
||||
});
|
||||
|
||||
it('should detect invalid requires references in user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create a schema with invalid requires reference
|
||||
const invalidRefSchema = `
|
||||
name: invalid-ref
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: a
|
||||
generates: a.md
|
||||
description: A
|
||||
template: a.md
|
||||
requires: [nonexistent]
|
||||
`;
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidRefSchema);
|
||||
|
||||
expect(() => resolveSchema('spec-driven')).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
it('should throw SchemaLoadError on YAML syntax errors', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
|
||||
// Create malformed YAML
|
||||
const malformedYaml = `
|
||||
name: bad
|
||||
version: [[[invalid yaml
|
||||
`;
|
||||
const schemaPath = path.join(userSchemaDir, 'schema.yaml');
|
||||
fs.writeFileSync(schemaPath, malformedYaml);
|
||||
|
||||
try {
|
||||
resolveSchema('spec-driven');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SchemaLoadError);
|
||||
const error = e as SchemaLoadError;
|
||||
expect(error.message).toContain('Failed to parse');
|
||||
expect(error.message).toContain(schemaPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to built-in when user override not found', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
// Don't create any user schemas
|
||||
|
||||
const schema = resolveSchema('spec-driven');
|
||||
|
||||
expect(schema.name).toBe('spec-driven');
|
||||
expect(schema.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw when schema not found', () => {
|
||||
expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('should list available schemas in error message', () => {
|
||||
try {
|
||||
resolveSchema('nonexistent');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
expect(error.message).toContain('spec-driven');
|
||||
expect(error.message).toContain('tdd');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSchemas', () => {
|
||||
it('should list built-in schemas', () => {
|
||||
const schemas = listSchemas();
|
||||
|
||||
expect(schemas).toContain('spec-driven');
|
||||
expect(schemas).toContain('tdd');
|
||||
});
|
||||
|
||||
it('should include user override schemas', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'custom-workflow');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []');
|
||||
|
||||
const schemas = listSchemas();
|
||||
|
||||
expect(schemas).toContain('custom-workflow');
|
||||
expect(schemas).toContain('spec-driven');
|
||||
});
|
||||
|
||||
it('should deduplicate schemas with same name', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');
|
||||
fs.mkdirSync(userSchemaDir, { recursive: true });
|
||||
// Override spec-driven
|
||||
fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []');
|
||||
|
||||
const schemas = listSchemas();
|
||||
|
||||
// Should only appear once
|
||||
const count = schemas.filter(s => s === 'spec-driven').length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('should return sorted list', () => {
|
||||
const schemas = listSchemas();
|
||||
|
||||
const sorted = [...schemas].sort();
|
||||
expect(schemas).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('should only include directories with schema.yaml', () => {
|
||||
process.env.XDG_DATA_HOME = tempDir;
|
||||
const userSchemasBase = path.join(tempDir, 'openspec', 'schemas');
|
||||
|
||||
// Create a directory without schema.yaml
|
||||
const emptyDir = path.join(userSchemasBase, 'empty-dir');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
// Create a valid schema directory
|
||||
const validDir = path.join(userSchemasBase, 'valid-schema');
|
||||
fs.mkdirSync(validDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(validDir, 'schema.yaml'), 'name: valid\nversion: 1\nartifacts: []');
|
||||
|
||||
const schemas = listSchemas();
|
||||
|
||||
expect(schemas).toContain('valid-schema');
|
||||
expect(schemas).not.toContain('empty-dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSchema, SchemaValidationError } from '../../../src/core/artifact-graph/schema.js';
|
||||
|
||||
describe('artifact-graph/schema', () => {
|
||||
describe('parseSchema', () => {
|
||||
it('should parse valid schema YAML', () => {
|
||||
const yaml = `
|
||||
name: test-schema
|
||||
version: 1
|
||||
description: A test schema
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Initial proposal
|
||||
template: templates/proposal.md
|
||||
requires: []
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: Design document
|
||||
template: templates/design.md
|
||||
requires:
|
||||
- proposal
|
||||
`;
|
||||
const schema = parseSchema(yaml);
|
||||
|
||||
expect(schema.name).toBe('test-schema');
|
||||
expect(schema.version).toBe(1);
|
||||
expect(schema.description).toBe('A test schema');
|
||||
expect(schema.artifacts).toHaveLength(2);
|
||||
expect(schema.artifacts[0].id).toBe('proposal');
|
||||
expect(schema.artifacts[1].requires).toEqual(['proposal']);
|
||||
});
|
||||
|
||||
it('should throw on missing required fields', () => {
|
||||
const yaml = `
|
||||
name: test-schema
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: proposal
|
||||
description: Missing generates and template
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/generates/);
|
||||
});
|
||||
|
||||
it('should throw on missing schema name', () => {
|
||||
const yaml = `
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Test
|
||||
template: templates/proposal.md
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/name/);
|
||||
});
|
||||
|
||||
it('should throw on invalid version (non-positive)', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 0
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: Test
|
||||
template: templates/proposal.md
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/positive/);
|
||||
});
|
||||
|
||||
it('should throw on empty artifacts array', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts: []
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/artifact/i);
|
||||
});
|
||||
|
||||
it('should throw on duplicate artifact IDs', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: proposal.md
|
||||
description: First
|
||||
template: templates/proposal.md
|
||||
- id: proposal
|
||||
generates: other.md
|
||||
description: Duplicate
|
||||
template: templates/other.md
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Duplicate artifact ID: proposal/);
|
||||
});
|
||||
|
||||
it('should throw on invalid requires reference', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: Design doc
|
||||
template: templates/design.md
|
||||
requires:
|
||||
- nonexistent
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Invalid dependency reference.*nonexistent/);
|
||||
});
|
||||
|
||||
it('should detect self-referencing cycle', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: A
|
||||
generates: a.md
|
||||
description: Self reference
|
||||
template: templates/a.md
|
||||
requires:
|
||||
- A
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
|
||||
});
|
||||
|
||||
it('should detect simple A → B → A cycle', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: A
|
||||
generates: a.md
|
||||
description: A
|
||||
template: templates/a.md
|
||||
requires:
|
||||
- B
|
||||
- id: B
|
||||
generates: b.md
|
||||
description: B
|
||||
template: templates/b.md
|
||||
requires:
|
||||
- A
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
|
||||
expect(() => parseSchema(yaml)).toThrow(/→/);
|
||||
});
|
||||
|
||||
it('should detect longer A → B → C → A cycle and list all IDs', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: A
|
||||
generates: a.md
|
||||
description: A
|
||||
template: templates/a.md
|
||||
requires:
|
||||
- C
|
||||
- id: B
|
||||
generates: b.md
|
||||
description: B
|
||||
template: templates/b.md
|
||||
requires:
|
||||
- A
|
||||
- id: C
|
||||
generates: c.md
|
||||
description: C
|
||||
template: templates/c.md
|
||||
requires:
|
||||
- B
|
||||
`;
|
||||
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
|
||||
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
|
||||
// Should contain all three in the cycle path
|
||||
const error = (() => {
|
||||
try {
|
||||
parseSchema(yaml);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})() as Error;
|
||||
expect(error.message).toMatch(/A.*→.*B|B.*→.*C|C.*→.*A/);
|
||||
});
|
||||
|
||||
it('should allow default empty requires array', () => {
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: root
|
||||
generates: root.md
|
||||
description: Root artifact
|
||||
template: templates/root.md
|
||||
`;
|
||||
const schema = parseSchema(yaml);
|
||||
expect(schema.artifacts[0].requires).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
|
||||
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
|
||||
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
|
||||
|
||||
describe('artifact-graph/state', () => {
|
||||
let tempDir: string;
|
||||
|
||||
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
|
||||
name: 'test',
|
||||
version: 1,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(os.tmpdir(), `openspec-state-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('detectCompleted', () => {
|
||||
it('should return empty set when changeDir does not exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const completed = detectCompleted(graph, '/nonexistent/path');
|
||||
|
||||
expect(completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty set when changeDir is empty', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should mark artifact complete when file exists', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create the file
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('proposal')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not mark artifact complete when file does not exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Only create proposal.md
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('proposal')).toBe(true);
|
||||
expect(completed.has('design')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested paths', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'nested', generates: 'docs/design.md', description: 'Nested', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create nested directory and file
|
||||
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'docs', 'design.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('nested')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect glob pattern as complete when files exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create specs directory with files
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-a.md'), 'content');
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-b.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not mark glob pattern complete when directory is empty', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create empty specs directory
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not mark glob pattern complete when directory does not exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not mark glob pattern complete when only non-matching files exist', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create specs directory with non-matching files
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'readme.txt'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts with mixed completion', () => {
|
||||
const schema = createSchema([
|
||||
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
|
||||
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: ['proposal'] },
|
||||
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: ['proposal'] },
|
||||
{ id: 'tasks', generates: 'tasks.md', description: 'Tasks', template: 't.md', requires: ['specs', 'design'] },
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create some files
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
|
||||
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'specs', 'auth.md'), 'content');
|
||||
// design.md and tasks.md do not exist
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
|
||||
expect(completed.has('proposal')).toBe(true);
|
||||
expect(completed.has('specs')).toBe(true);
|
||||
expect(completed.has('design')).toBe(false);
|
||||
expect(completed.has('tasks')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { resolveSchema } from '../../../src/core/artifact-graph/resolver.js';
|
||||
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
|
||||
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
|
||||
import type { BlockedArtifacts } from '../../../src/core/artifact-graph/types.js';
|
||||
|
||||
/**
|
||||
* Normalize BlockedArtifacts for comparison by sorting dependency arrays.
|
||||
* The order of unmet dependencies is not guaranteed, so we sort for stable assertions.
|
||||
*/
|
||||
function normalizeBlocked(blocked: BlockedArtifacts): BlockedArtifacts {
|
||||
const normalized: BlockedArtifacts = {};
|
||||
for (const [key, deps] of Object.entries(blocked)) {
|
||||
normalized[key] = [...deps].sort();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
describe('artifact-graph workflow integration', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use a unique temp directory for each test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workflow-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory after each test
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('spec-driven workflow', () => {
|
||||
it('should progress through complete workflow', () => {
|
||||
// 1. Resolve the real built-in schema
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Verify schema structure
|
||||
expect(graph.getName()).toBe('spec-driven');
|
||||
expect(graph.getAllArtifacts()).toHaveLength(4);
|
||||
|
||||
// 2. Initial state - nothing complete, only proposal is ready
|
||||
let completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.size).toBe(0);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
|
||||
expect(graph.isComplete(completed)).toBe(false);
|
||||
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
|
||||
specs: ['proposal'],
|
||||
design: ['proposal'],
|
||||
tasks: ['design', 'specs'],
|
||||
});
|
||||
|
||||
// 3. Create proposal.md - now specs and design become ready
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal\n\nInitial proposal content.');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal']));
|
||||
expect(graph.getNextArtifacts(completed).sort()).toEqual(['design', 'specs']);
|
||||
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
|
||||
tasks: ['design', 'specs'],
|
||||
});
|
||||
|
||||
// 4. Create design.md - specs still needed for tasks
|
||||
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design\n\nTechnical design content.');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design']));
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
|
||||
expect(graph.getBlocked(completed)).toEqual({
|
||||
tasks: ['specs'],
|
||||
});
|
||||
|
||||
// 5. Create specs directory with a spec file - tasks becomes ready
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(specsDir, 'feature-auth.md'), '# Auth Spec\n\nAuthentication specification.');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design', 'specs']));
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['tasks']);
|
||||
expect(graph.getBlocked(completed)).toEqual({});
|
||||
|
||||
// 6. Create tasks.md - workflow complete
|
||||
fs.writeFileSync(path.join(tempDir, 'tasks.md'), '# Tasks\n\n- [ ] Implement feature');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design', 'specs', 'tasks']));
|
||||
expect(graph.getNextArtifacts(completed)).toEqual([]);
|
||||
expect(graph.isComplete(completed)).toBe(true);
|
||||
expect(graph.getBlocked(completed)).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle out-of-order file creation', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create files in wrong order - design before proposal
|
||||
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design');
|
||||
|
||||
let completed = detectCompleted(graph, tempDir);
|
||||
// design file exists but it's still marked complete (filesystem-based)
|
||||
expect(completed).toEqual(new Set(['design']));
|
||||
// proposal is still the only "ready" artifact since it has no deps
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
|
||||
|
||||
// Now create proposal
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(completed).toEqual(new Set(['proposal', 'design']));
|
||||
// specs is the only thing ready now (design already done)
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
|
||||
});
|
||||
|
||||
it('should handle multiple spec files in glob pattern', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Complete prerequisites
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
|
||||
|
||||
// Create specs directory with multiple files
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(specsDir, 'auth.md'), '# Auth');
|
||||
fs.writeFileSync(path.join(specsDir, 'api.md'), '# API');
|
||||
fs.writeFileSync(path.join(specsDir, 'database.md'), '# Database');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.has('specs')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tdd workflow', () => {
|
||||
it('should progress through complete workflow', () => {
|
||||
const schema = resolveSchema('tdd');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
expect(graph.getName()).toBe('tdd');
|
||||
expect(graph.getBuildOrder()).toEqual(['spec', 'tests', 'implementation', 'docs']);
|
||||
|
||||
// Initial state
|
||||
let completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['spec']);
|
||||
|
||||
// Create spec
|
||||
fs.writeFileSync(path.join(tempDir, 'spec.md'), '# Feature Spec');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['tests']);
|
||||
|
||||
// Create tests directory with test file
|
||||
const testsDir = path.join(tempDir, 'tests');
|
||||
fs.mkdirSync(testsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(testsDir, 'feature.test.ts'), 'describe("feature", () => {});');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['implementation']);
|
||||
|
||||
// Create src directory with implementation
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'feature.ts'), 'export function feature() {}');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['docs']);
|
||||
|
||||
// Create docs
|
||||
const docsDir = path.join(tempDir, 'docs');
|
||||
fs.mkdirSync(docsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(docsDir, 'feature.md'), '# Feature Documentation');
|
||||
completed = detectCompleted(graph, tempDir);
|
||||
expect(graph.isComplete(completed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('build order consistency', () => {
|
||||
it('should return consistent build order across multiple calls', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const order1 = graph.getBuildOrder();
|
||||
const order2 = graph.getBuildOrder();
|
||||
const order3 = graph.getBuildOrder();
|
||||
|
||||
expect(order1).toEqual(order2);
|
||||
expect(order2).toEqual(order3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty and edge cases', () => {
|
||||
it('should handle empty change directory gracefully', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Directory exists but is empty
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.size).toBe(0);
|
||||
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
|
||||
});
|
||||
|
||||
it('should handle non-existent change directory', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
const nonExistentDir = path.join(tempDir, 'does-not-exist');
|
||||
const completed = detectCompleted(graph, nonExistentDir);
|
||||
expect(completed.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should not count non-matching files in glob directories', () => {
|
||||
const schema = resolveSchema('spec-driven');
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
// Create specs directory with wrong file types
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(specsDir, 'notes.txt'), 'not a markdown file');
|
||||
fs.writeFileSync(path.join(specsDir, 'data.json'), '{}');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.has('specs')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
getNestedValue,
|
||||
setNestedValue,
|
||||
deleteNestedValue,
|
||||
coerceValue,
|
||||
formatValueYaml,
|
||||
validateConfig,
|
||||
GlobalConfigSchema,
|
||||
DEFAULT_CONFIG,
|
||||
} from '../../src/core/config-schema.js';
|
||||
|
||||
describe('config-schema', () => {
|
||||
describe('getNestedValue', () => {
|
||||
it('should get a top-level value', () => {
|
||||
const obj = { foo: 'bar' };
|
||||
expect(getNestedValue(obj, 'foo')).toBe('bar');
|
||||
});
|
||||
|
||||
it('should get a nested value with dot notation', () => {
|
||||
const obj = { a: { b: { c: 'deep' } } };
|
||||
expect(getNestedValue(obj, 'a.b.c')).toBe('deep');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent path', () => {
|
||||
const obj = { foo: 'bar' };
|
||||
expect(getNestedValue(obj, 'baz')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent nested path', () => {
|
||||
const obj = { a: { b: 'value' } };
|
||||
expect(getNestedValue(obj, 'a.b.c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when traversing through null', () => {
|
||||
const obj = { a: null };
|
||||
expect(getNestedValue(obj as Record<string, unknown>, 'a.b')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when traversing through primitive', () => {
|
||||
const obj = { a: 'string' };
|
||||
expect(getNestedValue(obj, 'a.b')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should get object values', () => {
|
||||
const obj = { a: { b: 'value' } };
|
||||
expect(getNestedValue(obj, 'a')).toEqual({ b: 'value' });
|
||||
});
|
||||
|
||||
it('should handle array values', () => {
|
||||
const obj = { arr: [1, 2, 3] };
|
||||
expect(getNestedValue(obj, 'arr')).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNestedValue', () => {
|
||||
it('should set a top-level value', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, 'foo', 'bar');
|
||||
expect(obj.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('should set a nested value', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, 'a.b.c', 'deep');
|
||||
expect((obj.a as Record<string, unknown>).b).toEqual({ c: 'deep' });
|
||||
});
|
||||
|
||||
it('should create intermediate objects', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, 'x.y.z', 'value');
|
||||
expect(obj).toEqual({ x: { y: { z: 'value' } } });
|
||||
});
|
||||
|
||||
it('should overwrite existing values', () => {
|
||||
const obj: Record<string, unknown> = { a: 'old' };
|
||||
setNestedValue(obj, 'a', 'new');
|
||||
expect(obj.a).toBe('new');
|
||||
});
|
||||
|
||||
it('should overwrite primitive with object when setting nested path', () => {
|
||||
const obj: Record<string, unknown> = { a: 'string' };
|
||||
setNestedValue(obj, 'a.b', 'value');
|
||||
expect(obj.a).toEqual({ b: 'value' });
|
||||
});
|
||||
|
||||
it('should preserve other keys when setting nested value', () => {
|
||||
const obj: Record<string, unknown> = { a: { existing: 'keep' } };
|
||||
setNestedValue(obj, 'a.new', 'added');
|
||||
expect(obj.a).toEqual({ existing: 'keep', new: 'added' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNestedValue', () => {
|
||||
it('should delete a top-level key', () => {
|
||||
const obj: Record<string, unknown> = { foo: 'bar', baz: 'qux' };
|
||||
const result = deleteNestedValue(obj, 'foo');
|
||||
expect(result).toBe(true);
|
||||
expect(obj).toEqual({ baz: 'qux' });
|
||||
});
|
||||
|
||||
it('should delete a nested key', () => {
|
||||
const obj: Record<string, unknown> = { a: { b: 'value', c: 'keep' } };
|
||||
const result = deleteNestedValue(obj, 'a.b');
|
||||
expect(result).toBe(true);
|
||||
expect(obj.a).toEqual({ c: 'keep' });
|
||||
});
|
||||
|
||||
it('should return false for non-existent key', () => {
|
||||
const obj: Record<string, unknown> = { foo: 'bar' };
|
||||
const result = deleteNestedValue(obj, 'baz');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-existent nested path', () => {
|
||||
const obj: Record<string, unknown> = { a: { b: 'value' } };
|
||||
const result = deleteNestedValue(obj, 'a.c');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when intermediate path does not exist', () => {
|
||||
const obj: Record<string, unknown> = { a: 'string' };
|
||||
const result = deleteNestedValue(obj, 'a.b.c');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceValue', () => {
|
||||
it('should coerce "true" to boolean true', () => {
|
||||
expect(coerceValue('true')).toBe(true);
|
||||
});
|
||||
|
||||
it('should coerce "false" to boolean false', () => {
|
||||
expect(coerceValue('false')).toBe(false);
|
||||
});
|
||||
|
||||
it('should coerce integer string to number', () => {
|
||||
expect(coerceValue('42')).toBe(42);
|
||||
});
|
||||
|
||||
it('should coerce float string to number', () => {
|
||||
expect(coerceValue('3.14')).toBe(3.14);
|
||||
});
|
||||
|
||||
it('should coerce negative number string to number', () => {
|
||||
expect(coerceValue('-10')).toBe(-10);
|
||||
});
|
||||
|
||||
it('should keep regular strings as strings', () => {
|
||||
expect(coerceValue('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should keep strings that start with numbers but are not numbers', () => {
|
||||
expect(coerceValue('123abc')).toBe('123abc');
|
||||
});
|
||||
|
||||
it('should keep empty string as string', () => {
|
||||
expect(coerceValue('')).toBe('');
|
||||
});
|
||||
|
||||
it('should keep whitespace-only string as string', () => {
|
||||
expect(coerceValue(' ')).toBe(' ');
|
||||
});
|
||||
|
||||
it('should force string when forceString is true', () => {
|
||||
expect(coerceValue('true', true)).toBe('true');
|
||||
expect(coerceValue('42', true)).toBe('42');
|
||||
expect(coerceValue('hello', true)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should not coerce Infinity to number (not finite)', () => {
|
||||
// Infinity is not a useful config value, so we keep it as string
|
||||
expect(coerceValue('Infinity')).toBe('Infinity');
|
||||
});
|
||||
|
||||
it('should handle scientific notation', () => {
|
||||
expect(coerceValue('1e10')).toBe(1e10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValueYaml', () => {
|
||||
it('should format null as "null"', () => {
|
||||
expect(formatValueYaml(null)).toBe('null');
|
||||
});
|
||||
|
||||
it('should format undefined as "null"', () => {
|
||||
expect(formatValueYaml(undefined)).toBe('null');
|
||||
});
|
||||
|
||||
it('should format boolean as string', () => {
|
||||
expect(formatValueYaml(true)).toBe('true');
|
||||
expect(formatValueYaml(false)).toBe('false');
|
||||
});
|
||||
|
||||
it('should format number as string', () => {
|
||||
expect(formatValueYaml(42)).toBe('42');
|
||||
expect(formatValueYaml(3.14)).toBe('3.14');
|
||||
});
|
||||
|
||||
it('should format string as-is', () => {
|
||||
expect(formatValueYaml('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should format empty array as "[]"', () => {
|
||||
expect(formatValueYaml([])).toBe('[]');
|
||||
});
|
||||
|
||||
it('should format empty object as "{}"', () => {
|
||||
expect(formatValueYaml({})).toBe('{}');
|
||||
});
|
||||
|
||||
it('should format object with key-value pairs', () => {
|
||||
const result = formatValueYaml({ foo: 'bar' });
|
||||
expect(result).toBe('foo: bar');
|
||||
});
|
||||
|
||||
it('should format nested objects with indentation', () => {
|
||||
const result = formatValueYaml({ a: { b: 'value' } });
|
||||
expect(result).toContain('a:');
|
||||
expect(result).toContain('b: value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should accept valid config with featureFlags', () => {
|
||||
const result = validateConfig({ featureFlags: { test: true } });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept empty featureFlags', () => {
|
||||
const result = validateConfig({ featureFlags: {} });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept config without featureFlags (uses default)', () => {
|
||||
const result = validateConfig({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept unknown fields (passthrough)', () => {
|
||||
const result = validateConfig({ featureFlags: {}, unknownField: 'value' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept unknown fields with various types', () => {
|
||||
const result = validateConfig({
|
||||
featureFlags: {},
|
||||
futureStringField: 'value',
|
||||
futureNumberField: 123,
|
||||
futureObjectField: { nested: 'data' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-boolean values in featureFlags', () => {
|
||||
const result = validateConfig({ featureFlags: { test: 'string' } });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include path in error message for invalid featureFlags', () => {
|
||||
const result = validateConfig({ featureFlags: { someFlag: 'notABoolean' } });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('featureFlags');
|
||||
});
|
||||
|
||||
it('should reject non-object featureFlags', () => {
|
||||
const result = validateConfig({ featureFlags: 'string' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject number values in featureFlags', () => {
|
||||
const result = validateConfig({ featureFlags: { flag: 123 } });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config set simulation', () => {
|
||||
// These tests simulate the full config set flow: coerce value → set nested → validate
|
||||
|
||||
it('should accept setting unknown top-level key (forward compatibility)', () => {
|
||||
const config: Record<string, unknown> = { featureFlags: {} };
|
||||
const value = coerceValue('123');
|
||||
setNestedValue(config, 'someFutureKey', value);
|
||||
|
||||
const result = validateConfig(config);
|
||||
expect(result.success).toBe(true);
|
||||
expect(config.someFutureKey).toBe(123);
|
||||
});
|
||||
|
||||
it('should reject setting non-boolean to featureFlags', () => {
|
||||
const config: Record<string, unknown> = { featureFlags: {} };
|
||||
const value = coerceValue('notABoolean'); // stays as string
|
||||
setNestedValue(config, 'featureFlags.someFlag', value);
|
||||
|
||||
const result = validateConfig(config);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('featureFlags');
|
||||
});
|
||||
|
||||
it('should accept setting boolean to featureFlags', () => {
|
||||
const config: Record<string, unknown> = { featureFlags: {} };
|
||||
const value = coerceValue('true'); // coerces to boolean
|
||||
setNestedValue(config, 'featureFlags.newFlag', value);
|
||||
|
||||
const result = validateConfig(config);
|
||||
expect(result.success).toBe(true);
|
||||
expect((config.featureFlags as Record<string, unknown>).newFlag).toBe(true);
|
||||
});
|
||||
|
||||
it('should create featureFlags object when setting nested flag', () => {
|
||||
const config: Record<string, unknown> = {};
|
||||
const value = coerceValue('false');
|
||||
setNestedValue(config, 'featureFlags.experimental', value);
|
||||
|
||||
const result = validateConfig(config);
|
||||
expect(result.success).toBe(true);
|
||||
expect((config.featureFlags as Record<string, unknown>).experimental).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlobalConfigSchema', () => {
|
||||
it('should parse valid config', () => {
|
||||
const result = GlobalConfigSchema.safeParse({ featureFlags: { test: true } });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should provide defaults for missing featureFlags', () => {
|
||||
const result = GlobalConfigSchema.parse({});
|
||||
expect(result.featureFlags).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_CONFIG', () => {
|
||||
it('should have empty featureFlags', () => {
|
||||
expect(DEFAULT_CONFIG.featureFlags).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,56 @@ describe('ViewCommand', () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('shows changes with no tasks in Draft section, not Completed', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
// Empty change (no tasks.md) - should show in Draft
|
||||
await fs.mkdir(path.join(changesDir, 'empty-change'), { recursive: true });
|
||||
|
||||
// Change with tasks.md but no tasks - should show in Draft
|
||||
await fs.mkdir(path.join(changesDir, 'no-tasks-change'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'no-tasks-change', 'tasks.md'), '# Tasks\n\nNo tasks yet.');
|
||||
|
||||
// Change with all tasks complete - should show in Completed
|
||||
await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(changesDir, 'completed-change', 'tasks.md'),
|
||||
'- [x] Done task\n'
|
||||
);
|
||||
|
||||
const viewCommand = new ViewCommand();
|
||||
await viewCommand.execute(tempDir);
|
||||
|
||||
const output = logOutput.map(stripAnsi).join('\n');
|
||||
|
||||
// Draft section should contain empty and no-tasks changes
|
||||
expect(output).toContain('Draft Changes');
|
||||
expect(output).toContain('empty-change');
|
||||
expect(output).toContain('no-tasks-change');
|
||||
|
||||
// Completed section should only contain changes with all tasks done
|
||||
expect(output).toContain('Completed Changes');
|
||||
expect(output).toContain('completed-change');
|
||||
|
||||
// Verify empty-change and no-tasks-change are in Draft section (marked with ○)
|
||||
const draftLines = logOutput
|
||||
.map(stripAnsi)
|
||||
.filter((line) => line.includes('○'));
|
||||
const draftNames = draftLines.map((line) => line.trim().replace('○ ', ''));
|
||||
expect(draftNames).toContain('empty-change');
|
||||
expect(draftNames).toContain('no-tasks-change');
|
||||
|
||||
// Verify completed-change is in Completed section (marked with ✓)
|
||||
const completedLines = logOutput
|
||||
.map(stripAnsi)
|
||||
.filter((line) => line.includes('✓'));
|
||||
const completedNames = completedLines.map((line) => line.trim().replace('✓ ', ''));
|
||||
expect(completedNames).toContain('completed-change');
|
||||
expect(completedNames).not.toContain('empty-change');
|
||||
expect(completedNames).not.toContain('no-tasks-change');
|
||||
});
|
||||
|
||||
it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
@@ -90,6 +90,9 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
// Prevent child process from keeping the event loop alive
|
||||
child.unref();
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
@@ -113,11 +116,19 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
// Explicitly destroy streams to prevent hanging handles
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
child.stdin?.destroy();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
// Explicitly destroy streams to prevent hanging handles
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
child.stdin?.destroy();
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { validateChangeName, createChange } from '../../src/utils/change-utils.js';
|
||||
|
||||
describe('validateChangeName', () => {
|
||||
describe('valid names', () => {
|
||||
it('should accept simple kebab-case name', () => {
|
||||
const result = validateChangeName('add-auth');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept name with multiple segments', () => {
|
||||
const result = validateChangeName('add-user-auth');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept name with numeric suffix', () => {
|
||||
const result = validateChangeName('add-feature-2');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept single word name', () => {
|
||||
const result = validateChangeName('refactor');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept name with numbers in segments', () => {
|
||||
const result = validateChangeName('upgrade-to-v2');
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - uppercase rejected', () => {
|
||||
it('should reject name with uppercase letters', () => {
|
||||
const result = validateChangeName('Add-Auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('lowercase');
|
||||
});
|
||||
|
||||
it('should reject fully uppercase name', () => {
|
||||
const result = validateChangeName('ADD-AUTH');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('lowercase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - spaces rejected', () => {
|
||||
it('should reject name with spaces', () => {
|
||||
const result = validateChangeName('add auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('spaces');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - underscores rejected', () => {
|
||||
it('should reject name with underscores', () => {
|
||||
const result = validateChangeName('add_auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('underscores');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - special characters rejected', () => {
|
||||
it('should reject name with exclamation mark', () => {
|
||||
const result = validateChangeName('add-auth!');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject name with @ symbol', () => {
|
||||
const result = validateChangeName('add@auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - leading/trailing hyphens rejected', () => {
|
||||
it('should reject name with leading hyphen', () => {
|
||||
const result = validateChangeName('-add-auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('start with a hyphen');
|
||||
});
|
||||
|
||||
it('should reject name with trailing hyphen', () => {
|
||||
const result = validateChangeName('add-auth-');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('end with a hyphen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - consecutive hyphens rejected', () => {
|
||||
it('should reject name with double hyphens', () => {
|
||||
const result = validateChangeName('add--auth');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('consecutive hyphens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid names - empty name rejected', () => {
|
||||
it('should reject empty string', () => {
|
||||
const result = validateChangeName('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('empty');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChange', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('creates directory', () => {
|
||||
it('should create change directory', async () => {
|
||||
await createChange(testDir, 'add-auth');
|
||||
|
||||
const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth');
|
||||
const stats = await fs.stat(changeDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate change throws error', () => {
|
||||
it('should throw error if change already exists', async () => {
|
||||
await createChange(testDir, 'add-auth');
|
||||
|
||||
await expect(createChange(testDir, 'add-auth')).rejects.toThrow(
|
||||
/already exists/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid name throws validation error', () => {
|
||||
it('should throw error for uppercase name', async () => {
|
||||
await expect(createChange(testDir, 'Add-Auth')).rejects.toThrow(
|
||||
/lowercase/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for name with spaces', async () => {
|
||||
await expect(createChange(testDir, 'add auth')).rejects.toThrow(
|
||||
/spaces/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty name', async () => {
|
||||
await expect(createChange(testDir, '')).rejects.toThrow(
|
||||
/empty/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('creates parent directories if needed', () => {
|
||||
it('should create openspec/changes/ directories if they do not exist', async () => {
|
||||
const newProjectDir = path.join(testDir, 'new-project');
|
||||
await fs.mkdir(newProjectDir);
|
||||
|
||||
// openspec/changes/ does not exist yet
|
||||
await createChange(newProjectDir, 'add-auth');
|
||||
|
||||
const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth');
|
||||
const stats = await fs.stat(changeDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { isInteractive, resolveNoInteractive, InteractiveOptions } from '../../src/utils/interactive.js';
|
||||
|
||||
describe('interactive utilities', () => {
|
||||
let originalOpenSpecInteractive: string | undefined;
|
||||
let originalCI: string | undefined;
|
||||
let originalStdinIsTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE;
|
||||
originalCI = process.env.CI;
|
||||
originalStdinIsTTY = process.stdin.isTTY;
|
||||
|
||||
// Clear environment for clean testing
|
||||
delete process.env.OPEN_SPEC_INTERACTIVE;
|
||||
delete process.env.CI;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
if (originalOpenSpecInteractive !== undefined) {
|
||||
process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive;
|
||||
} else {
|
||||
delete process.env.OPEN_SPEC_INTERACTIVE;
|
||||
}
|
||||
if (originalCI !== undefined) {
|
||||
process.env.CI = originalCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
// Restore stdin.isTTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNoInteractive', () => {
|
||||
it('should return true when noInteractive is true', () => {
|
||||
expect(resolveNoInteractive({ noInteractive: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when interactive is false (Commander.js style)', () => {
|
||||
// This is how Commander.js handles --no-interactive flag
|
||||
expect(resolveNoInteractive({ interactive: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when noInteractive is false', () => {
|
||||
expect(resolveNoInteractive({ noInteractive: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when interactive is true', () => {
|
||||
expect(resolveNoInteractive({ interactive: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty options object', () => {
|
||||
expect(resolveNoInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(resolveNoInteractive(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle boolean value true', () => {
|
||||
expect(resolveNoInteractive(true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle boolean value false', () => {
|
||||
expect(resolveNoInteractive(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize noInteractive over interactive when both set', () => {
|
||||
// noInteractive: true should win
|
||||
expect(resolveNoInteractive({ noInteractive: true, interactive: true })).toBe(true);
|
||||
// If noInteractive is false, check interactive
|
||||
expect(resolveNoInteractive({ noInteractive: false, interactive: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInteractive', () => {
|
||||
it('should return false when noInteractive is true', () => {
|
||||
expect(isInteractive({ noInteractive: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when interactive is false (Commander.js --no-interactive)', () => {
|
||||
expect(isInteractive({ interactive: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when OPEN_SPEC_INTERACTIVE env var is 0', () => {
|
||||
process.env.OPEN_SPEC_INTERACTIVE = '0';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI env var is set', () => {
|
||||
process.env.CI = 'true';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI env var is set to any value', () => {
|
||||
// CI can be set to any value, not just "true"
|
||||
process.env.CI = '1';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when stdin is not a TTY', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when stdin is TTY and no flags disable it', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when stdin is TTY and options are undefined', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
]
|
||||
},
|
||||
testTimeout: 10000,
|
||||
hookTimeout: 10000
|
||||
hookTimeout: 10000,
|
||||
teardownTimeout: 3000
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,3 +4,9 @@ import { ensureCliBuilt } from './test/helpers/run-cli.js';
|
||||
export async function setup() {
|
||||
await ensureCliBuilt();
|
||||
}
|
||||
|
||||
// Global teardown to ensure clean exit
|
||||
export async function teardown() {
|
||||
// Clear any remaining timers
|
||||
// This helps prevent hanging handles from keeping the process alive
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user