Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale 03ea32369a fix(cli): prevent hang in pre-commit hooks by using dynamic imports
Fixes #367

The CLI was hanging when run as a pre-commit hook because @inquirer/prompts
was statically imported at module load time. Even when prompts were never
called (e.g., `openspec validate --specs --no-interactive`), the import
itself could set up stdin references that prevented clean process exit
when stdin was piped.

Changes:
- Convert all static `@inquirer/prompts` imports to dynamic imports
- Dynamically import `InitCommand` (which uses `@inquirer/core`)
- Update `isInteractive()` to accept options object with both
  `noInteractive` and Commander's negated `interactive` property
- Handle empty validation queue with proper exit code

Now when running in non-interactive mode, the inquirer modules are never
loaded, allowing the process to exit cleanly after completion.
2025-12-21 17:48:32 +11:00
60 changed files with 80 additions and 6309 deletions
-3
View File
@@ -142,9 +142,6 @@ jobs:
- name: Type check
run: pnpm exec tsc --noEmit
- name: Lint
run: pnpm lint
- name: Check for build artifacts
run: |
if [ ! -d "dist" ]; then
+5 -3
View File
@@ -7,7 +7,6 @@ on:
permissions:
contents: write
pull-requests: write
id-token: write # Required for npm OIDC trusted publishing
concurrency:
group: release-${{ github.ref }}
@@ -28,9 +27,11 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC
node-version: '20'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
scope: '@fission-ai'
always-auth: true
- run: pnpm install --frozen-lockfile
@@ -45,4 +46,5 @@ jobs:
publish: pnpm run release:ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# npm authentication handled via OIDC trusted publishing (no token needed)
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+2
View File
@@ -140,6 +140,8 @@ dist/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Internal Docs
docs/
# Claude
.claude/
-38
View File
@@ -1,43 +1,5 @@
# @fission-ai/openspec
## 0.17.2
### Patch Changes
- 455c65f: Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments
## 0.17.1
### Patch Changes
- 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
-593
View File
@@ -1,593 +0,0 @@
# POC-OpenSpec-Core Analysis
---
## Design Decisions & Terminology
### Philosophy: Not a Workflow System
This system is **not** a workflow engine. It's an **artifact tracker with dependency awareness**.
| What it's NOT | What it IS |
|---------------|------------|
| Linear step-by-step progression | Exploratory, iterative planning |
| Bureaucratic checkpoints | Enablers that unlock possibilities |
| "You must complete step 1 first" | "Here's what you could create now" |
| Form-filling | Fluid document creation |
**Key insight:** Dependencies are *enablers*, not *gates*. You can't meaningfully write a design document if there's no proposal to design from - that's not bureaucracy, it's logic.
### Terminology
| Term | Definition | Example |
|------|------------|---------|
| **Change** | A unit of work being planned (feature, refactor, migration) | `openspec/changes/add-auth/` |
| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `spec-driven.yaml` |
| **Artifact** | A node in the graph (a document to create) | `proposal`, `design`, `specs` |
| **Template** | Instructions/guidance for creating an artifact | `templates/proposal.md` |
### Hierarchy
```
Schema (defines) ──→ Artifacts (guided by) ──→ Templates
```
- **Schema** = the artifact graph (what exists, dependencies)
- **Artifact** = a document to produce
- **Template** = instructions for creating that artifact
### Schema Variations
Schemas can vary across multiple dimensions:
| Dimension | Examples |
|-----------|----------|
| Philosophy | `spec-driven`, `tdd`, `prototype-first` |
| Version | `v1`, `v2`, `v3` |
| Language | `en`, `zh`, `es` |
| Custom | `team-alpha`, `experimental` |
### Schema Resolution (XDG Standard)
Schemas follow the XDG Base Directory Specification with a 2-level resolution:
```
1. ${XDG_DATA_HOME}/openspec/schemas/<name>.yaml # Global user override
2. <package>/schemas/<name>.yaml # Built-in defaults
```
**Platform-specific paths:**
- Unix/macOS: `~/.local/share/openspec/schemas/`
- Windows: `%LOCALAPPDATA%/openspec/schemas/`
- All platforms: `$XDG_DATA_HOME/openspec/schemas/` (when set)
**Why XDG?**
- Schemas are workflow definitions (data), not user preferences (config)
- Built-ins baked into package, never auto-copied
- Users customize by creating files in global data dir
- Consistent with modern CLI tooling standards
### Template Inheritance (2 Levels Max)
Templates also use 2-level resolution (to be implemented in Slice 3):
```
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
2. ${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
3. <package>/templates/<artifact>.md # Built-in fallback
```
**Rules:**
- Schema-specific templates override shared templates
- Shared templates override package built-ins
- A CLI command shows resolved paths (no guessing)
- No inheritance between schemas (copy if you need to diverge)
- Max 2 levels - no deeper inheritance chains
**Why this matters:**
- Avoids "where does this come from?" debugging
- No implicit magic that works until it doesn't
- Clear boundaries between shared and specific
---
## Executive Summary
This is an **artifact tracker with dependency awareness** that guides iterative development through a structured artifact pipeline. The core innovation is using the **filesystem as a database** - artifact completion is detected by file existence, making the system stateless and version-control friendly.
The system answers:
- "What artifacts exist for this change?"
- "What could I create next?" (not "what must I create")
- "What's blocking X?" (informational, not prescriptive)
---
## Core Components
### 1. ArtifactGraph (Slice 1 - COMPLETE)
The dependency graph engine with XDG-compliant schema resolution.
| Responsibility | Approach |
|----------------|----------|
| Model artifacts as a DAG | Artifact with `requires: string[]` |
| Track completion state | `Set<string>` for completed artifacts |
| Calculate build order | Kahn's algorithm (topological sort) |
| Find ready artifacts | Check if all dependencies are in `completed` set |
| Resolve schemas | XDG global → package built-ins |
**Key Data Structures (Zod-validated):**
```typescript
// Zod schemas define types + validation
const ArtifactSchema = z.object({
id: z.string().min(1),
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
description: z.string(),
template: z.string(), // path to template file
requires: z.array(z.string()).default([]),
});
const SchemaYamlSchema = z.object({
name: z.string().min(1),
version: z.number().int().positive(),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1),
});
// Derived types
type Artifact = z.infer<typeof ArtifactSchema>;
type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
```
**Key Methods:**
- `resolveSchema(name)` - Load schema with XDG fallback
- `ArtifactGraph.fromSchema(schema)` - Build graph from schema
- `detectState(graph, changeDir)` - Scan filesystem for completion
- `getNextArtifacts(graph, completed)` - Find artifacts ready to create
- `getBuildOrder(graph)` - Topological sort of all artifacts
- `getBlocked(graph, completed)` - Artifacts with unmet dependencies
---
### 2. Change Utilities (Slice 2)
Simple utility functions for programmatic change creation. No class, no abstraction layer.
| Responsibility | Approach |
|----------------|----------|
| Create changes | Create dirs under `openspec/changes/<name>/` with README |
| Name validation | Enforce kebab-case naming |
**Key Paths:**
```
openspec/changes/<name>/ → Change instances with artifacts (project-level)
```
**Key Functions** (`src/utils/change-utils.ts`):
- `createChange(projectRoot, name, description?)` - Create new change directory + README
- `validateChangeName(name)` - Validate kebab-case naming, returns `{ valid, error? }`
**Note:** Existing CLI commands (`ListCommand`, `ChangeCommand`) already handle listing, path resolution, and existence checks. No need to extract that logic - it works fine as-is.
---
### 3. InstructionLoader (Slice 3)
Template resolution and instruction enrichment.
| Responsibility | Approach |
|----------------|----------|
| Resolve templates | XDG 2-level fallback (schema-specific → shared → built-in) |
| Build dynamic context | Gather dependency status, change info |
| Enrich templates | Inject context into base templates |
| Generate status reports | Formatted markdown with progress |
**Key Class - ChangeState:**
```
ChangeState {
changeName: string
changeDir: string
graph: ArtifactGraph
completed: Set<string>
// Methods
getNextSteps(): string[]
getStatus(artifactId): ArtifactStatus
isComplete(): boolean
}
```
**Key Functions:**
- `getTemplatePath(artifactId, schemaName?)` - Resolve with 2-level fallback
- `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point
- `getChangeStatus(projectRoot, changeName?)` - Formatted status report
---
### 4. CLI (Slice 4)
User interface layer. **All commands are deterministic** - require explicit `--change` parameter.
| Command | Function | Status |
|---------|----------|--------|
| `status --change <id>` | Show change progress (artifact graph) | **NEW** |
| `next --change <id>` | Show artifacts ready to create | **NEW** |
| `instructions <artifact> --change <id>` | Get enriched instructions for artifact | **NEW** |
| `list` | List all changes | EXISTS (`openspec change list`) |
| `new <name>` | Create change | **NEW** (uses `createChange()`) |
| `init` | Initialize structure | EXISTS (`openspec init`) |
| `templates --change <id>` | Show resolved template paths | **NEW** |
**Note:** Commands that operate on a change require `--change`. Missing parameter → error with list of available changes. Agent infers the change from conversation and passes it explicitly.
**Existing CLI commands** (not part of this slice):
- `openspec change list` / `openspec change show <id>` / `openspec change validate <id>`
- `openspec list --changes` / `openspec list --specs`
- `openspec view` (dashboard)
- `openspec init` / `openspec archive <change>`
---
### 5. Claude Commands
Integration layer for Claude Code. **Operational commands only** - artifact creation via natural language.
| Command | Purpose |
|---------|---------|
| `/status` | Show change progress |
| `/next` | Show what's ready to create |
| `/run [artifact]` | Execute a specific step (power users) |
| `/list` | List all changes |
| `/new <name>` | Create a new change |
| `/init` | Initialize structure |
**Artifact creation:** Users say "create the proposal" or "write the tests" in natural language. The agent:
1. Infers change from conversation (confirms if uncertain)
2. Infers artifact from request
3. Calls CLI with explicit `--change` parameter
4. Creates artifact following instructions
This works for ANY artifact in ANY schema - no new slash commands needed when schemas change.
**Note:** Legacy commands (`/openspec-proposal`, `/openspec-apply`, `/openspec-archive`) exist in the main project for backward compatibility but are separate from this architecture.
---
## Component Dependency Graph
```
┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ CLI │ ←─shell exec───────│ Claude Commands │ │
│ └──────┬───────┘ └────────────────────┘ │
└─────────┼───────────────────────────────────────────────────┘
│ imports
▼
┌─────────────────────────────────────────────────────────────┐
│ ORCHESTRATION LAYER │
│ ┌────────────────────┐ ┌──────────────────────────┐ │
│ │ InstructionLoader │ │ change-utils (Slice 2) │ │
│ │ (Slice 3) │ │ createChange() │ │
│ └─────────┬──────────┘ │ validateChangeName() │ │
│ │ └──────────────────────────┘ │
└────────────┼────────────────────────────────────────────────┘
│ uses
▼
┌─────────────────────────────────────────────────────────────┐
│ CORE LAYER │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ArtifactGraph (Slice 1) │ │
│ │ │ │
│ │ Schema Resolution (XDG) ──→ Graph ──→ State Detection│ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲
│ reads from
▼
┌─────────────────────────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ ┌──────────────────┐ ┌────────────────────────────────┐ │
│ │ XDG Schemas │ │ Project Artifacts │ │
│ │ ~/.local/share/ │ │ openspec/changes/<name>/ │ │
│ │ openspec/ │ │ - proposal.md, design.md │ │
│ │ schemas/ │ │ - specs/*.md, tasks.md │ │
│ └──────────────────┘ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Key Design Patterns
### 1. Filesystem as Database
No SQLite, no JSON state files. The existence of `proposal.md` means proposal is complete.
```
// State detection is just file existence checking
if (exists(artifactPath)) {
completed.add(artifactId)
}
```
### 2. Deterministic CLI, Inferring Agent
**CLI layer:** Always deterministic - requires explicit `--change` parameter.
```
openspec status --change add-auth # explicit, works
openspec status # error: "No change specified"
```
**Agent layer:** Infers from conversation, confirms if uncertain, passes explicit `--change`.
This separation means:
- CLI is pure, testable, no state to corrupt
- Agent handles all "smartness"
- No config.yaml tracking of "active change"
### 3. XDG-Compliant Schema Resolution
```
${XDG_DATA_HOME}/openspec/schemas/<name>.yaml # User override
↓ (not found)
<package>/schemas/<name>.yaml # Built-in
↓ (not found)
Error (schema not found)
```
### 4. Two-Level Template Fallback (Slice 3)
```
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
↓ (not found)
${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
↓ (not found)
<package>/templates/<artifact>.md # Built-in
↓ (not found)
Error (no silent fallback to avoid confusion)
```
### 5. Glob Pattern Support
`specs/*.md` allows multiple files to satisfy a single artifact:
```
if (artifact.generates.includes("*")) {
const parentDir = changeDir / patternParts[0]
if (exists(parentDir) && hasFiles(parentDir)) {
completed.add(artifactId)
}
}
```
### 6. Stateless State Detection
Every command re-scans the filesystem. No cached state to corrupt.
---
## Artifact Pipeline (Default Schema)
The default `spec-driven` schema:
```
┌──────────┐
│ proposal │ (no dependencies)
└────┬─────┘
│
▼
┌──────────┐
│ specs │ (requires: proposal)
└────┬─────┘
│
├──────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ design │ │ │
│ │◄──┤ proposal │
└────┬─────┘ └──────────┘
│ (requires: proposal, specs)
▼
┌──────────┐
│ tasks │ (requires: design)
└──────────┘
```
Other schemas (TDD, prototype-first) would have different graphs.
---
## Implementation Order
Structured as **vertical slices** - each slice is independently testable.
---
### Slice 1: "What's Ready?" (Core Query) ✅ COMPLETE
**Delivers:** Types + Graph + State Detection + Schema Resolution
**Implementation:** `src/core/artifact-graph/`
- `types.ts` - Zod schemas and derived TypeScript types
- `schema.ts` - YAML parsing with Zod validation
- `graph.ts` - ArtifactGraph class with topological sort
- `state.ts` - Filesystem-based state detection
- `resolver.ts` - XDG-compliant schema resolution
- `builtin-schemas.ts` - Package-bundled default schemas
**Key decisions made:**
- Zod for schema validation (consistent with project)
- XDG for global schema overrides
- `Set<string>` for completion state (immutable, functional)
- `inProgress` and `failed` states deferred (require external tracking)
---
### Slice 2: "Change Creation Utilities"
**Delivers:** Utility functions for programmatic change creation
**Scope:**
- `createChange(projectRoot, name, description?)` → creates directory + README
- `validateChangeName(name)` → kebab-case pattern enforcement
**Not in scope (already exists in CLI commands):**
- `listChanges()` → exists in `ListCommand` and `ChangeCommand.getActiveChanges()`
- `getChangePath()` → simple `path.join()` inline
- `changeExists()` → simple `fs.access()` inline
- `isInitialized()` → simple directory check inline
**Why simplified:** Extracting existing CLI logic into a class would require similar refactoring of `SpecCommand` for consistency. The existing code works fine (~15 lines each). Only truly new functionality is `createChange()` + name validation.
---
### Slice 3: "Get Instructions" (Enrichment)
**Delivers:** Template resolution + context injection
**Testable behaviors:**
- Template fallback: schema-specific → shared → built-in → error
- Context injection: completed deps show ✓, missing show ✗
- Output path shown correctly based on change directory
---
### Slice 4: "CLI + Integration"
**Delivers:** New artifact graph commands (builds on existing CLI)
**New commands:**
- `status --change <id>` - Show artifact completion state
- `next --change <id>` - Show ready-to-create artifacts
- `instructions <artifact> --change <id>` - Get enriched template
- `templates --change <id>` - Show resolved paths
- `new <name>` - Create change (wrapper for `createChange()`)
**Already exists (not in scope):**
- `openspec change list/show/validate` - change management
- `openspec list --changes/--specs` - listing
- `openspec view` - dashboard
- `openspec init` - initialization
**Testable behaviors:**
- Each new command produces expected output
- Commands compose correctly (status → next → instructions flow)
- Error handling for missing changes, invalid artifacts, etc.
---
## Directory Structure
```
# Global (XDG paths - user overrides)
~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/)
%LOCALAPPDATA%/openspec/ # Windows
├── schemas/ # Schema overrides
│ └── custom-workflow.yaml # User-defined schema
└── templates/ # Template overrides (Slice 3)
└── proposal.md # Custom proposal template
# Package (built-in defaults)
<package>/
├── schemas/ # Built-in schema definitions
│ ├── spec-driven.yaml # Default: proposal → specs → design → tasks
│ └── tdd.yaml # TDD: tests → implementation → docs
└── templates/ # Built-in templates (Slice 3)
├── proposal.md
├── design.md
├── specs.md
└── tasks.md
# Project (change instances)
openspec/
└── changes/ # Change instances
├── add-auth/
│ ├── README.md # Auto-generated on creation
│ ├── proposal.md # Created artifacts
│ ├── design.md
│ └── specs/
│ └── *.md
├── refactor-db/
│ └── ...
└── archive/ # Completed changes
└── 2025-01-01-add-auth/
.claude/
├── settings.local.json # Permissions
└── commands/ # Slash commands
└── *.md
```
---
## Schema YAML Format
```yaml
# Built-in: <package>/schemas/spec-driven.yaml
# Or user override: ~/.local/share/openspec/schemas/spec-driven.yaml
name: spec-driven
version: 1
description: Specification-driven development
artifacts:
- id: proposal
generates: "proposal.md"
description: "Create project proposal document"
template: "proposal.md" # resolves via 2-level fallback (Slice 3)
requires: []
- id: specs
generates: "specs/*.md" # glob pattern
description: "Create technical specification documents"
template: "specs.md"
requires:
- proposal
- id: design
generates: "design.md"
description: "Create design document"
template: "design.md"
requires:
- proposal
- specs
- id: tasks
generates: "tasks.md"
description: "Create tasks breakdown document"
template: "tasks.md"
requires:
- design
```
---
## Summary
| Layer | Component | Responsibility | Status |
|-------|-----------|----------------|--------|
| Core | ArtifactGraph | Pure dependency logic + XDG schema resolution | ✅ Slice 1 COMPLETE |
| Utils | change-utils | Change creation + name validation only | Slice 2 (new functionality only) |
| Core | InstructionLoader | Template resolution + enrichment | Slice 3 (all new) |
| Presentation | CLI | New artifact graph commands | Slice 4 (new commands only) |
| Integration | Claude Commands | AI assistant glue | Slice 4 |
**What already exists (not in this proposal):**
- `getActiveChangeIds()` in `src/utils/item-discovery.ts` - list changes
- `ChangeCommand.list/show/validate()` in `src/commands/change.ts`
- `ListCommand.execute()` in `src/core/list.ts`
- `ViewCommand.execute()` in `src/core/view.ts` - dashboard
- `src/core/init.ts` - initialization
- `src/core/archive.ts` - archiving
**Key Principles:**
- **Filesystem IS the database** - stateless, version-control friendly
- **Dependencies are enablers** - show what's possible, don't force order
- **Deterministic CLI, inferring agent** - CLI requires explicit `--change`, agent infers from context
- **XDG-compliant paths** - schemas and templates use standard user data directories
- **2-level inheritance** - user override → package built-in (no deeper)
- **Schemas are versioned** - support variations by philosophy, version, language
-42
View File
@@ -1,42 +0,0 @@
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'],
}
);
@@ -0,0 +1,39 @@
## 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
@@ -1,89 +0,0 @@
## 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.
@@ -1,60 +0,0 @@
## 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)
@@ -1,213 +0,0 @@
# 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
@@ -1,28 +0,0 @@
## 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)
@@ -1,197 +0,0 @@
## Context
This implements "Slice 1: What's Ready?" from the artifact POC analysis. The core insight is using the filesystem as a database - artifact completion is detected by file existence, making the system stateless and version-control friendly.
This module will coexist with the current OpenSpec system as a parallel capability, potentially enabling future migration or integration.
## Goals / Non-Goals
**Goals:**
- Pure dependency graph logic with no side effects
- Stateless state detection (rescan filesystem each query)
- Support glob patterns for multi-file artifacts (e.g., `specs/*.md`)
- Load artifact definitions from YAML schemas
- Calculate topological build order
- Determine "ready" artifacts based on dependency completion
**Non-Goals:**
- CLI commands (Slice 4)
- Multi-change management (Slice 2)
- Template resolution and enrichment (Slice 3)
- Agent integration or Claude commands
- Replacing existing OpenSpec functionality
## Decisions
### Decision: Filesystem as Database
Use file existence for state detection rather than a separate state file.
**Rationale:**
- Stateless - no state corruption possible
- Git-friendly - state derived from committed files
- Simple - no sync issues between state file and actual files
**Alternatives considered:**
- JSON/SQLite state file: More complex, sync issues, not git-friendly
- Git metadata: Too coupled to git, complex implementation
### Decision: Kahn's Algorithm for Topological Sort
Use Kahn's algorithm for computing build order.
**Rationale:**
- Well-understood, O(V+E) complexity
- Naturally detects cycles during execution
- Produces a stable, deterministic order
### Decision: Glob Pattern Support
Support glob patterns like `specs/*.md` in artifact `generates` field.
**Rationale:**
- Allows multiple files to satisfy a single artifact requirement
- Common pattern for spec directories with multiple files
- Uses standard glob syntax
### Decision: Immutable Completed Set
Represent completion state as an immutable Set of completed artifact IDs.
**Rationale:**
- Functional style, easier to reason about
- State derived fresh each query, no mutation needed
- Clear separation between graph structure and runtime state
- Filesystem can only detect binary existence (complete vs not complete)
**Note:** `inProgress` and `failed` states are deferred to future slices. They would require external state tracking (e.g., a status file) since file existence alone cannot distinguish these states.
### Decision: Zod for Schema Validation
Use Zod for validating YAML schema structure and deriving TypeScript types.
**Rationale:**
- Already a project dependency (v4.0.17) used in `src/core/schemas/`
- Type inference via `z.infer<>` - single source of truth for types
- Runtime validation with detailed error messages
- Consistent with existing project patterns (`base.schema.ts`, `config-schema.ts`)
**Alternatives considered:**
- Manual validation: More code, error-prone, no type inference
- JSON Schema: Would require additional dependency, less TypeScript integration
- io-ts: Not already in project, steeper learning curve
### Decision: Two-Level Schema Resolution
Schemas resolve from global user data directory, falling back to package built-ins.
**Resolution order:**
1. `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml` - Global user override
2. `<package>/schemas/<name>.yaml` - Built-in defaults
**Rationale:**
- Follows XDG Base Directory Specification (schemas are data, not config)
- Mirrors existing `getGlobalConfigDir()` pattern in `src/core/global-paths.ts`
- Built-ins baked into package, never auto-copied
- Users customize by creating files in global data dir
- Simple - no project-level overrides (can add later if needed)
**XDG compliance:**
- Uses `XDG_DATA_HOME` env var when set (all platforms)
- Unix/macOS fallback: `~/.local/share/openspec/`
- Windows fallback: `%LOCALAPPDATA%/openspec/`
**Alternatives considered:**
- Project-level overrides: Added complexity, not needed initially
- Auto-copy to user space: Creates drift, harder to update defaults
- Config directory (`XDG_CONFIG_HOME`): Schemas are workflow definitions (data), not user preferences (config)
### Decision: Template Field Parsed But Not Resolved
The `template` field is required in schema YAML for completeness, but template resolution is deferred to Slice 3.
**Rationale:**
- Slice 1 focuses on "What's Ready?" - dependency and completion queries only
- Template paths are validated syntactically (non-empty string) but not resolved
- Keeps Slice 1 focused and independently testable
### Decision: Cycle Error Format
Cycle errors list all artifact IDs in the cycle for easy debugging.
**Format:** `"Cyclic dependency detected: A → B → C → A"`
**Rationale:**
- Shows the full cycle path, not just that a cycle exists
- Actionable - developer can see exactly which artifacts to fix
- Consistent with Kahn's algorithm which naturally identifies cycle participants
## Data Structures
**Zod Schemas (source of truth):**
```typescript
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, 'Artifact ID is required'),
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
description: z.string(),
template: z.string(), // path to template file
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, 'Schema name is required'),
version: z.number().int().positive(),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, 'At least one artifact required'),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
```
**Runtime State (not Zod - internal only):**
```typescript
// Slice 1: Simple completion tracking via filesystem
type CompletedSet = Set<string>;
// Return type for blocked query
interface BlockedArtifacts {
[artifactId: string]: string[]; // artifact → list of unmet dependencies
}
interface ArtifactGraphResult {
completed: string[];
ready: string[];
blocked: BlockedArtifacts;
buildOrder: string[];
}
```
## File Structure
```
src/core/artifact-graph/
├── index.ts # Public exports
├── types.ts # Zod schemas and type definitions
├── graph.ts # ArtifactGraph class
├── state.ts # State detection logic
├── resolver.ts # Schema resolution (global → built-in)
└── schemas/ # Built-in schema definitions (package level)
├── spec-driven.yaml # Default: proposal → specs → design → tasks
└── tdd.yaml # Alternative: tests → implementation → docs
```
**Schema Resolution Paths:**
- Global user override: `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml`
- Package built-in: `src/core/artifact-graph/schemas/<name>.yaml` (bundled with package)
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Glob pattern edge cases | Use well-tested glob library (fast-glob or similar) |
| Cycle detection | Kahn's algorithm naturally fails on cycles; provide clear error |
| Schema evolution | Version field in schema, validate on load |
## Open Questions
None - all questions resolved in Decisions section.
@@ -1,18 +0,0 @@
## Why
The current OpenSpec system relies on conventions and AI inference for artifact ordering. A formal artifact graph with dependency awareness would enable deterministic "what's ready?" queries, making the system more predictable and enabling future features like automated pipeline execution.
## What Changes
- Add `ArtifactGraph` class to model artifacts as a DAG with dependency relationships
- Add `ArtifactState` type to track completion status (completed, in_progress, failed)
- Add filesystem-based state detection using file existence and glob patterns
- Add schema YAML parser to load artifact definitions
- Implement topological sort (Kahn's algorithm) for build order calculation
- Add `getNextArtifacts()` to find artifacts ready for creation
## Impact
- Affected specs: New `artifact-graph` capability
- Affected code: `src/core/artifact-graph/` (new directory)
- No changes to existing functionality - this is a parallel module
@@ -1,103 +0,0 @@
## ADDED Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files.
#### Scenario: Valid schema loaded
- **WHEN** a valid schema YAML file is provided
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
@@ -1,61 +0,0 @@
## 1. Type Definitions
- [x] 1.1 Create `src/core/artifact-graph/types.ts` with Zod schemas (`ArtifactSchema`, `SchemaYamlSchema`) and inferred types via `z.infer<>`
- [x] 1.2 Define `CompletedSet` (Set<string>), `BlockedArtifacts`, and `ArtifactGraphResult` types for runtime state
## 2. Schema Parser
- [x] 2.1 Create `src/core/artifact-graph/schema.ts` with YAML loading and Zod validation via `.safeParse()`
- [x] 2.2 Implement dependency reference validation (ensure `requires` references valid artifact IDs)
- [x] 2.3 Implement duplicate artifact ID detection
- [x] 2.4 Add cycle detection during schema load (error format: "Cyclic dependency detected: A → B → C → A")
## 3. Artifact Graph Core
- [x] 3.1 Create `src/core/artifact-graph/graph.ts` with ArtifactGraph class
- [x] 3.2 Implement `fromYaml(path)` - load graph from schema file
- [x] 3.3 Implement `getBuildOrder()` - topological sort via Kahn's algorithm
- [x] 3.4 Implement `getArtifact(id)` - retrieve single artifact definition
- [x] 3.5 Implement `getAllArtifacts()` - list all artifacts
## 4. State Detection
- [x] 4.1 Create `src/core/artifact-graph/state.ts` with state detection logic
- [x] 4.2 Implement file existence checking for simple paths
- [x] 4.3 Implement glob pattern matching for multi-file artifacts
- [x] 4.4 Implement `detectCompleted(graph, changeDir)` - scan filesystem and return CompletedSet
- [x] 4.5 Handle missing changeDir gracefully (return empty CompletedSet)
## 5. Ready Calculation
- [x] 5.1 Implement `getNextArtifacts(graph, completed)` - find artifacts with all deps completed
- [x] 5.2 Implement `isComplete(graph, completed)` - check if all artifacts done
- [x] 5.3 Implement `getBlocked(graph, completed)` - return BlockedArtifacts map (artifact → unmet deps)
## 6. Schema Resolution
- [x] 6.1 Create `src/core/artifact-graph/resolver.ts` with schema resolution logic
- [x] 6.2 Add `getGlobalDataDir()` to `src/core/global-config.ts` (XDG_DATA_HOME with platform fallbacks)
- [x] 6.3 Implement `resolveSchema(name)` - global (`${XDG_DATA_HOME}/openspec/schemas/`) → built-in fallback
## 7. Built-in Schemas
- [x] 7.1 Create `src/core/artifact-graph/schemas/spec-driven.yaml` (default: proposal → specs → design → tasks)
- [x] 7.2 Create `src/core/artifact-graph/schemas/tdd.yaml` (alternative: tests → implementation → docs)
## 8. Integration
- [x] 8.1 Create `src/core/artifact-graph/index.ts` with public exports
## 9. Testing
- [x] 9.1 Test: Parse valid schema YAML returns correct artifact graph
- [x] 9.2 Test: Parse invalid schema (missing fields) throws descriptive error
- [x] 9.3 Test: Duplicate artifact IDs throws error
- [x] 9.4 Test: Invalid `requires` reference throws error identifying the invalid ID
- [x] 9.5 Test: Cycle in schema throws error listing cycle path (e.g., "A → B → C → A")
- [x] 9.6 Test: Compute build order returns correct topological ordering (linear chain)
- [x] 9.7 Test: Compute build order handles diamond dependencies correctly
- [x] 9.8 Test: Independent artifacts return in stable order
- [x] 9.9 Test: Empty/missing changeDir returns empty CompletedSet
- [x] 9.10 Test: File existence marks artifact as completed
- [x] 9.11 Test: Glob pattern specs/*.md detected as complete when files exist
- [x] 9.12 Test: Glob pattern with empty directory not marked complete
- [x] 9.13 Test: getNextArtifacts returns only root artifacts when nothing completed
- [x] 9.14 Test: getNextArtifacts includes artifact when all deps completed
- [x] 9.15 Test: getBlocked returns artifact with all unmet dependencies listed
- [x] 9.16 Test: isComplete() returns true when all artifacts completed
- [x] 9.17 Test: isComplete() returns false when some artifacts incomplete
- [x] 9.18 Test: Schema resolution finds global override before built-in
- [x] 9.19 Test: Schema resolution falls back to built-in when no global
@@ -1,74 +0,0 @@
## Context
This is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically.
**Current state:** No programmatic way to create changes. Users must manually create directories.
**Proposed state:** Utility functions for change creation with name validation.
## Goals / Non-Goals
### Goals
- **Add** `createChange()` function to create change directories
- **Add** `validateChangeName()` function for kebab-case validation
- **Enable** automation (Claude commands, scripts) to create changes
### Non-Goals
- Refactor existing CLI commands (they work fine)
- Create abstraction layers or manager classes
- Change how `ListCommand` or `ChangeCommand` work
## Decisions
### Decision 1: Simple Utility Functions
**Choice**: Add functions to `src/utils/change-utils.ts` - no class.
```typescript
// src/utils/change-utils.ts
export function validateChangeName(name: string): { valid: boolean; error?: string }
export async function createChange(
projectRoot: string,
name: string
): Promise<void>
```
**Why**:
- Simple, no abstraction overhead
- Easy to test
- Easy to import where needed
- Matches existing utility patterns in `src/utils/`
**Alternatives considered**:
- ChangeManager class: Rejected - over-engineered for 2 functions
- Add to existing command: Rejected - mixes CLI with reusable logic
### Decision 2: Kebab-Case Validation Pattern
**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
Valid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor`
Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth`
**Why**:
- Filesystem-safe (no special characters)
- URL-safe (for future web UI)
- Consistent with existing change naming in repo
## File Changes
### New Files
- `src/utils/change-utils.ts` - Utility functions
- `src/utils/change-utils.test.ts` - Unit tests
### Modified Files
- None
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Function might not cover all use cases | Start simple, extend if needed |
| Naming conflicts with future work | Using clear, specific function names |
@@ -1,45 +0,0 @@
## Why
There's no programmatic way to create a new change directory. Users must manually:
1. Create `openspec/changes/<name>/` directory
2. Create a `proposal.md` file
3. Hope they got the naming right
This is error-prone and blocks automation (e.g., Claude commands, scripts).
**This proposal adds:**
1. `createChange(projectRoot, name)` - Create change directories programmatically
2. `validateChangeName(name)` - Enforce kebab-case naming conventions
## What Changes
### New Utilities
| Function | Description |
|----------|-------------|
| `createChange(projectRoot, name)` | Creates `openspec/changes/<name>/` directory |
| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` |
### Name Validation Rules
Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
| Valid | Invalid |
|-------|---------|
| `add-auth` | `Add-Auth` (uppercase) |
| `refactor-db` | `add auth` (spaces) |
| `add-feature-2` | `add_auth` (underscores) |
| `refactor` | `-add-auth` (leading hyphen) |
### Location
New file: `src/utils/change-utils.ts`
Simple utility functions - no class, no abstraction layer.
## Impact
- **Affected specs**: None
- **Affected code**: None (new utilities only)
- **New files**: `src/utils/change-utils.ts`
- **Breaking changes**: None
@@ -1,63 +0,0 @@
## ADDED Requirements
### Requirement: Change Creation
The system SHALL provide a function to create new change directories programmatically.
#### Scenario: Create change
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
- **THEN** the system creates `openspec/changes/add-auth/` directory
#### Scenario: Duplicate change rejected
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
- **THEN** the system throws an error indicating the change already exists
#### Scenario: Creates parent directories if needed
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
- **THEN** the system creates the full path including parent directories
#### Scenario: Invalid change name rejected
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
- **THEN** the system throws a validation error
### Requirement: Change Name Validation
The system SHALL validate change names follow kebab-case conventions.
#### Scenario: Valid kebab-case name accepted
- **WHEN** a change name like `add-user-auth` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Numeric suffixes accepted
- **WHEN** a change name like `add-feature-2` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Single word accepted
- **WHEN** a change name like `refactor` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Uppercase characters rejected
- **WHEN** a change name like `Add-Auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Spaces rejected
- **WHEN** a change name like `add auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Underscores rejected
- **WHEN** a change name like `add_auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Special characters rejected
- **WHEN** a change name like `add-auth!` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Leading hyphen rejected
- **WHEN** a change name like `-add-auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Trailing hyphen rejected
- **WHEN** a change name like `add-auth-` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Consecutive hyphens rejected
- **WHEN** a change name like `add--auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
@@ -1,30 +0,0 @@
## Phase 1: Implement Name Validation
- [x] 1.1 Create `src/utils/change-utils.ts`
- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern
- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
- [x] 1.4 Return `{ valid: boolean; error?: string }`
- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)
- [x] 1.6 Add test: uppercase rejected
- [x] 1.7 Add test: spaces rejected
- [x] 1.8 Add test: underscores rejected
- [x] 1.9 Add test: special characters rejected
- [x] 1.10 Add test: leading/trailing hyphens rejected
- [x] 1.11 Add test: consecutive hyphens rejected
## Phase 2: Implement Change Creation
- [x] 2.1 Implement `createChange(projectRoot, name)`
- [x] 2.2 Validate name before creating
- [x] 2.3 Create parent directories if needed (`openspec/changes/`)
- [x] 2.4 Throw if change already exists
- [x] 2.5 Add test: creates directory
- [x] 2.6 Add test: duplicate change throws error
- [x] 2.7 Add test: invalid name throws validation error
- [x] 2.8 Add test: creates parent directories if needed
## Phase 3: Integration
- [x] 3.1 Export functions from `src/utils/index.ts`
- [x] 3.2 Add JSDoc comments
- [x] 3.3 Run all tests to verify no regressions
-107
View File
@@ -1,107 +0,0 @@
# artifact-graph Specification
## Purpose
TBD - created by archiving change add-artifact-graph-core. Update Purpose after archive.
## Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files.
#### Scenario: Valid schema loaded
- **WHEN** a valid schema YAML file is provided
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
-67
View File
@@ -1,67 +0,0 @@
# change-creation Specification
## Purpose
Provide programmatic utilities for creating and validating OpenSpec change directories.
## Requirements
### Requirement: Change Creation
The system SHALL provide a function to create new change directories programmatically.
#### Scenario: Create change
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
- **THEN** the system creates `openspec/changes/add-auth/` directory
#### Scenario: Duplicate change rejected
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
- **THEN** the system throws an error indicating the change already exists
#### Scenario: Creates parent directories if needed
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
- **THEN** the system creates the full path including parent directories
#### Scenario: Invalid change name rejected
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
- **THEN** the system throws a validation error
### Requirement: Change Name Validation
The system SHALL validate change names follow kebab-case conventions.
#### Scenario: Valid kebab-case name accepted
- **WHEN** a change name like `add-user-auth` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Numeric suffixes accepted
- **WHEN** a change name like `add-feature-2` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Single word accepted
- **WHEN** a change name like `refactor` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Uppercase characters rejected
- **WHEN** a change name like `Add-Auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Spaces rejected
- **WHEN** a change name like `add auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Underscores rejected
- **WHEN** a change name like `add_auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Special characters rejected
- **WHEN** a change name like `add-auth!` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Leading hyphen rejected
- **WHEN** a change name like `-add-auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Trailing hyphen rejected
- **WHEN** a change name like `add-auth-` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Consecutive hyphens rejected
- **WHEN** a change name like `add--auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
-217
View File
@@ -1,217 +0,0 @@
# 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
+1 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.17.2",
"version": "0.16.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -38,7 +38,6 @@
"!dist/**/*.map"
],
"scripts": {
"lint": "eslint src/",
"build": "node build.js",
"dev": "tsc --watch",
"dev:cli": "pnpm build && node bin/openspec.js",
@@ -63,9 +62,7 @@
"@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": {
@@ -73,9 +70,7 @@
"@inquirer/prompts": "^7.8.0",
"chalk": "^5.5.0",
"commander": "^14.0.0",
"fast-glob": "^3.3.3",
"ora": "^8.2.0",
"yaml": "^2.8.2",
"zod": "^4.0.17"
}
}
+11 -787
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -13,7 +13,6 @@ 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';
const program = new Command();
const require = createRequire(import.meta.url);
@@ -201,7 +200,6 @@ program
});
registerSpecCommand(program);
registerConfigCommand(program);
// Top-level validate command
program
-233
View File
@@ -1,233 +0,0 @@
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;
}
});
}
+4 -5
View File
@@ -1,7 +1,7 @@
import ora from 'ora';
import path from 'path';
import { Validator } from '../core/validation/validator.js';
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { nearestMatches } from '../utils/match.js';
@@ -15,7 +15,6 @@ interface ExecuteOptions {
strict?: boolean;
json?: boolean;
noInteractive?: boolean;
interactive?: boolean; // Commander sets this to false when --no-interactive is used
concurrency?: string;
}
@@ -36,7 +35,7 @@ export class ValidateCommand {
await this.runBulkValidation({
changes: !!options.all || !!options.changes,
specs: !!options.all || !!options.specs,
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
return;
}
@@ -181,8 +180,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; noInteractive?: boolean }): Promise<void> {
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
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;
const [changeIds, specIds] = await Promise.all([
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
+7 -23
View File
@@ -1,5 +1,6 @@
import { promises as fs } from 'fs';
import path from 'path';
import { FileSystemUtils } from '../utils/file-system.js';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
@@ -446,26 +447,15 @@ export class ArchiveCommand {
// Load or create base target content
let targetContent: string;
let isNewSpec = false;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
} catch {
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
// REMOVED will be ignored with a warning since there's nothing to remove
if (plan.modified.length > 0 || plan.renamed.length > 0) {
// Target spec does not exist; only ADDED operations are permitted
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
throw new Error(
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`
);
}
// Warn about REMOVED requirements being ignored for new specs
if (plan.removed.length > 0) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`
)
);
}
isNewSpec = true;
targetContent = this.buildSpecSkeleton(specName, changeName);
}
@@ -508,15 +498,9 @@ export class ArchiveCommand {
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
if (!nameToBlock.has(key)) {
// For new specs, REMOVED requirements are already warned about and ignored
// For existing specs, missing requirements are an error
if (!isNewSpec) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
}
// Skip removal for new specs (already warned above)
continue;
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
}
nameToBlock.delete(key);
}
@@ -1,84 +0,0 @@
import type { SchemaYaml } from './types.js';
/**
* Built-in schema definitions.
* These are compiled into the package, avoiding runtime file resolution issues.
*/
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
name: 'spec-driven',
version: 1,
description: 'Default OpenSpec workflow - proposal → specs → design → tasks',
artifacts: [
{
id: 'proposal',
generates: 'proposal.md',
description: 'Initial proposal document outlining the change',
template: 'templates/proposal.md',
requires: [],
},
{
id: 'specs',
generates: 'specs/*.md',
description: 'Detailed specifications for the change',
template: 'templates/spec.md',
requires: ['proposal'],
},
{
id: 'design',
generates: 'design.md',
description: 'Technical design document with implementation details',
template: 'templates/design.md',
requires: ['proposal'],
},
{
id: 'tasks',
generates: 'tasks.md',
description: 'Implementation tasks derived from specs and design',
template: 'templates/tasks.md',
requires: ['specs', 'design'],
},
],
};
export const TDD_SCHEMA: SchemaYaml = {
name: 'tdd',
version: 1,
description: 'Test-driven development workflow - tests → implementation → docs',
artifacts: [
{
id: 'spec',
generates: 'spec.md',
description: 'Feature specification defining requirements',
template: 'templates/spec.md',
requires: [],
},
{
id: 'tests',
generates: 'tests/*.test.ts',
description: 'Test files written before implementation',
template: 'templates/test.md',
requires: ['spec'],
},
{
id: 'implementation',
generates: 'src/*.ts',
description: 'Implementation code to pass the tests',
template: 'templates/implementation.md',
requires: ['tests'],
},
{
id: 'docs',
generates: 'docs/*.md',
description: 'Documentation for the implemented feature',
template: 'templates/docs.md',
requires: ['implementation'],
},
],
};
/** Map of built-in schema names to their definitions */
export const BUILTIN_SCHEMAS: Record<string, SchemaYaml> = {
'spec-driven': SPEC_DRIVEN_SCHEMA,
'tdd': TDD_SCHEMA,
};
-167
View File
@@ -1,167 +0,0 @@
import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './types.js';
import { loadSchema, parseSchema } from './schema.js';
/**
* Represents an artifact dependency graph.
* Provides methods for querying build order, ready artifacts, and completion status.
*/
export class ArtifactGraph {
private artifacts: Map<string, Artifact>;
private schema: SchemaYaml;
private constructor(schema: SchemaYaml) {
this.schema = schema;
this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
}
/**
* Creates an ArtifactGraph from a YAML file path.
*/
static fromYaml(filePath: string): ArtifactGraph {
const schema = loadSchema(filePath);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from YAML content string.
*/
static fromYamlContent(yamlContent: string): ArtifactGraph {
const schema = parseSchema(yamlContent);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from a pre-validated schema object.
*/
static fromSchema(schema: SchemaYaml): ArtifactGraph {
return new ArtifactGraph(schema);
}
/**
* Gets a single artifact by ID.
*/
getArtifact(id: string): Artifact | undefined {
return this.artifacts.get(id);
}
/**
* Gets all artifacts in the graph.
*/
getAllArtifacts(): Artifact[] {
return Array.from(this.artifacts.values());
}
/**
* Gets the schema name.
*/
getName(): string {
return this.schema.name;
}
/**
* Gets the schema version.
*/
getVersion(): number {
return this.schema.version;
}
/**
* Computes the topological build order using Kahn's algorithm.
* Returns artifact IDs in the order they should be built.
*/
getBuildOrder(): string[] {
const inDegree = new Map<string, number>();
const dependents = new Map<string, string[]>();
// Initialize all artifacts
for (const artifact of this.artifacts.values()) {
inDegree.set(artifact.id, artifact.requires.length);
dependents.set(artifact.id, []);
}
// Build reverse adjacency (who depends on whom)
for (const artifact of this.artifacts.values()) {
for (const req of artifact.requires) {
dependents.get(req)!.push(artifact.id);
}
}
// Start with roots (in-degree 0), sorted for determinism
const queue = [...this.artifacts.keys()]
.filter(id => inDegree.get(id) === 0)
.sort();
const result: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
result.push(current);
// Collect newly ready artifacts, then sort before adding
const newlyReady: string[] = [];
for (const dep of dependents.get(current)!) {
const newDegree = inDegree.get(dep)! - 1;
inDegree.set(dep, newDegree);
if (newDegree === 0) {
newlyReady.push(dep);
}
}
queue.push(...newlyReady.sort());
}
return result;
}
/**
* Gets artifacts that are ready to be created (all dependencies completed).
*/
getNextArtifacts(completed: CompletedSet): string[] {
const ready: string[] = [];
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const allDepsCompleted = artifact.requires.every(req => completed.has(req));
if (allDepsCompleted) {
ready.push(artifact.id);
}
}
// Sort for deterministic ordering
return ready.sort();
}
/**
* Checks if all artifacts in the graph are completed.
*/
isComplete(completed: CompletedSet): boolean {
for (const artifact of this.artifacts.values()) {
if (!completed.has(artifact.id)) {
return false;
}
}
return true;
}
/**
* Gets blocked artifacts and their unmet dependencies.
*/
getBlocked(completed: CompletedSet): BlockedArtifacts {
const blocked: BlockedArtifacts = {};
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const unmetDeps = artifact.requires.filter(req => !completed.has(req));
if (unmetDeps.length > 0) {
blocked[artifact.id] = unmetDeps.sort();
}
}
return blocked;
}
}
-24
View File
@@ -1,24 +0,0 @@
// Types
export {
ArtifactSchema,
SchemaYamlSchema,
type Artifact,
type SchemaYaml,
type CompletedSet,
type BlockedArtifacts,
} from './types.js';
// Schema loading and validation
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
// Graph operations
export { ArtifactGraph } from './graph.js';
// State detection
export { detectCompleted } from './state.js';
// Schema resolution
export { resolveSchema, listSchemas } from './resolver.js';
// Built-in schemas
export { BUILTIN_SCHEMAS, SPEC_DRIVEN_SCHEMA, TDD_SCHEMA } from './builtin-schemas.js';
-121
View File
@@ -1,121 +0,0 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getGlobalDataDir } from '../global-config.js';
import { BUILTIN_SCHEMAS } from './builtin-schemas.js';
import { parseSchema, SchemaValidationError } from './schema.js';
import type { SchemaYaml } from './types.js';
/**
* Error thrown when loading a global schema override fails.
*/
export class SchemaLoadError extends Error {
constructor(
message: string,
public readonly schemaPath: string,
public readonly cause?: Error
) {
super(message);
this.name = 'SchemaLoadError';
}
}
/**
* Resolves a schema name to a SchemaYaml object.
*
* Resolution order:
* 1. Global user override: ${XDG_DATA_HOME}/openspec/schemas/<name>.yaml
* 2. Built-in schema
*
* @param name - Schema name (e.g., "spec-driven")
* @returns The resolved schema object
* @throws Error if schema is not found in any location
*/
export function resolveSchema(name: string): SchemaYaml {
// Normalize name (remove .yaml extension if provided)
const normalizedName = name.replace(/\.ya?ml$/, '');
const builtinNames = Object.keys(BUILTIN_SCHEMAS).join(', ');
// 1. Check global user override (returns path if found)
const globalPath = getGlobalSchemaPath(normalizedName);
if (globalPath) {
// User override found - load and validate through the same pipeline as other schemas
let content: string;
try {
content = fs.readFileSync(globalPath, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to read global schema override at '${globalPath}': ${ioError.message}`,
globalPath,
ioError
);
}
try {
return parseSchema(content);
} catch (err) {
if (err instanceof SchemaValidationError) {
// Re-wrap validation errors to include the file path for context
throw new SchemaLoadError(
`Invalid global schema override at '${globalPath}': ${err.message}`,
globalPath,
err
);
}
// Handle unexpected parse errors (e.g., YAML syntax errors)
const parseError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to parse global schema override at '${globalPath}': ${parseError.message}`,
globalPath,
parseError
);
}
}
// 2. Check built-in schemas
const builtin = BUILTIN_SCHEMAS[normalizedName];
if (builtin) {
return builtin;
}
throw new Error(
`Schema '${normalizedName}' not found. Checked global overrides and built-in schemas. Available built-ins: ${builtinNames}`
);
}
/**
* Gets the path to a global user override schema, if it exists.
*/
function getGlobalSchemaPath(name: string): string | null {
const globalDir = path.join(getGlobalDataDir(), 'schemas');
// Check both .yaml and .yml extensions
for (const ext of ['.yaml', '.yml']) {
const schemaPath = path.join(globalDir, `${name}${ext}`);
if (fs.existsSync(schemaPath)) {
return schemaPath;
}
}
return null;
}
/**
* Lists all available schema names.
* Combines built-in and user override schemas.
*/
export function listSchemas(): string[] {
const schemas = new Set<string>(Object.keys(BUILTIN_SCHEMAS));
// Add user override schemas
const globalDir = path.join(getGlobalDataDir(), 'schemas');
if (fs.existsSync(globalDir)) {
for (const file of fs.readdirSync(globalDir)) {
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
schemas.add(file.replace(/\.ya?ml$/, ''));
}
}
}
return Array.from(schemas).sort();
}
-124
View File
@@ -1,124 +0,0 @@
import * as fs from 'node:fs';
import { parse as parseYaml } from 'yaml';
import { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';
export class SchemaValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SchemaValidationError';
}
}
/**
* Loads and validates an artifact schema from a YAML file.
*/
export function loadSchema(filePath: string): SchemaYaml {
const content = fs.readFileSync(filePath, 'utf-8');
return parseSchema(content);
}
/**
* Parses and validates an artifact schema from YAML content.
*/
export function parseSchema(yamlContent: string): SchemaYaml {
const parsed = parseYaml(yamlContent);
// Validate with Zod
const result = SchemaYamlSchema.safeParse(parsed);
if (!result.success) {
const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
throw new SchemaValidationError(`Invalid schema: ${errors}`);
}
const schema = result.data;
// Check for duplicate artifact IDs
validateNoDuplicateIds(schema.artifacts);
// Check that all requires references are valid
validateRequiresReferences(schema.artifacts);
// Check for cycles
validateNoCycles(schema.artifacts);
return schema;
}
/**
* Validates that there are no duplicate artifact IDs.
*/
function validateNoDuplicateIds(artifacts: Artifact[]): void {
const seen = new Set<string>();
for (const artifact of artifacts) {
if (seen.has(artifact.id)) {
throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);
}
seen.add(artifact.id);
}
}
/**
* Validates that all `requires` references point to valid artifact IDs.
*/
function validateRequiresReferences(artifacts: Artifact[]): void {
const validIds = new Set(artifacts.map(a => a.id));
for (const artifact of artifacts) {
for (const req of artifact.requires) {
if (!validIds.has(req)) {
throw new SchemaValidationError(
`Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`
);
}
}
}
}
/**
* Validates that there are no cyclic dependencies.
* Uses DFS to detect cycles and reports the full cycle path.
*/
function validateNoCycles(artifacts: Artifact[]): void {
const artifactMap = new Map(artifacts.map(a => [a.id, a]));
const visited = new Set<string>();
const inStack = new Set<string>();
const parent = new Map<string, string>();
function dfs(id: string): string | null {
visited.add(id);
inStack.add(id);
const artifact = artifactMap.get(id);
if (!artifact) return null;
for (const dep of artifact.requires) {
if (!visited.has(dep)) {
parent.set(dep, id);
const cycle = dfs(dep);
if (cycle) return cycle;
} else if (inStack.has(dep)) {
// Found a cycle - reconstruct the path
const cyclePath = [dep];
let current = id;
while (current !== dep) {
cyclePath.unshift(current);
current = parent.get(current)!;
}
cyclePath.unshift(dep);
return cyclePath.join(' → ');
}
}
inStack.delete(id);
return null;
}
for (const artifact of artifacts) {
if (!visited.has(artifact.id)) {
const cycle = dfs(artifact.id);
if (cycle) {
throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);
}
}
}
}
-64
View File
@@ -1,64 +0,0 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import fg from 'fast-glob';
import type { CompletedSet } from './types.js';
import type { ArtifactGraph } from './graph.js';
import { FileSystemUtils } from '../../utils/file-system.js';
/**
* Detects which artifacts are completed by checking file existence in the change directory.
* Returns a Set of completed artifact IDs.
*
* @param graph - The artifact graph to check
* @param changeDir - The change directory to scan for files
* @returns Set of artifact IDs whose generated files exist
*/
export function detectCompleted(graph: ArtifactGraph, changeDir: string): CompletedSet {
const completed = new Set<string>();
// Handle missing change directory gracefully
if (!fs.existsSync(changeDir)) {
return completed;
}
for (const artifact of graph.getAllArtifacts()) {
if (isArtifactComplete(artifact.generates, changeDir)) {
completed.add(artifact.id);
}
}
return completed;
}
/**
* Checks if an artifact is complete by checking if its generated file(s) exist.
* Supports both simple paths and glob patterns.
*/
function isArtifactComplete(generates: string, changeDir: string): boolean {
const fullPattern = path.join(changeDir, generates);
// Check if it's a glob pattern
if (isGlobPattern(generates)) {
return hasGlobMatches(fullPattern);
}
// Simple file path - check if file exists
return fs.existsSync(fullPattern);
}
/**
* Checks if a path contains glob pattern characters.
*/
function isGlobPattern(pattern: string): boolean {
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
}
/**
* Checks if a glob pattern has any matches.
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
*/
function hasGlobMatches(pattern: string): boolean {
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
return matches.length > 0;
}
-33
View File
@@ -1,33 +0,0 @@
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, { error: 'Artifact ID is required' }),
generates: z.string().min(1, { error: 'generates field is required' }),
description: z.string(),
template: z.string().min(1, { error: 'template field is required' }),
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, { error: 'Schema name is required' }),
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
// Runtime state types (not Zod - internal only)
// Slice 1: Simple completion tracking via filesystem
export type CompletedSet = Set<string>;
// Return type for blocked query
export interface BlockedArtifacts {
[artifactId: string]: string[];
}
-73
View File
@@ -288,77 +288,4 @@ 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: [],
},
],
},
];
-230
View File
@@ -1,230 +0,0 @@
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' };
}
}
+1 -2
View File
@@ -3,7 +3,6 @@ import path from 'path';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { Spec, Change } from '../schemas/index.js';
import { FileSystemUtils } from '../../utils/file-system.js';
export class JsonConverter {
convertSpecToJson(filePath: string): string {
@@ -44,7 +43,7 @@ export class JsonConverter {
}
private extractNameFromPath(filePath: string): string {
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
const normalizedPath = filePath.replaceAll('\\', '/');
const parts = normalizedPath.split('/');
for (let i = parts.length - 1; i >= 0; i--) {
-32
View File
@@ -5,7 +5,6 @@ import * as os from 'node:os';
// Constants
export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
export const GLOBAL_DATA_DIR_NAME = 'openspec';
// TypeScript interfaces
export interface GlobalConfig {
@@ -46,37 +45,6 @@ export function getGlobalConfigDir(): string {
return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
}
/**
* Gets the global data directory path following XDG Base Directory Specification.
* Used for user data like schema overrides.
*
* - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set
* - Unix/macOS fallback: ~/.local/share/openspec/
* - Windows fallback: %LOCALAPPDATA%/openspec/
*/
export function getGlobalDataDir(): string {
// XDG_DATA_HOME takes precedence on all platforms when explicitly set
const xdgDataHome = process.env.XDG_DATA_HOME;
if (xdgDataHome) {
return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);
}
const platform = os.platform();
if (platform === 'win32') {
// Windows: use %LOCALAPPDATA%
const localAppData = process.env.LOCALAPPDATA;
if (localAppData) {
return path.join(localAppData, GLOBAL_DATA_DIR_NAME);
}
// Fallback for Windows if LOCALAPPDATA is not set
return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);
}
// Unix/macOS fallback: ~/.local/share
return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);
}
/**
* Gets the path to the global config file.
*/
+1 -3
View File
@@ -2,11 +2,9 @@
export {
GLOBAL_CONFIG_DIR_NAME,
GLOBAL_CONFIG_FILE_NAME,
GLOBAL_DATA_DIR_NAME,
type GlobalConfig,
getGlobalConfigDir,
getGlobalConfigPath,
getGlobalConfig,
saveGlobalConfig,
getGlobalDataDir
saveGlobalConfig
} from './global-config.js';
+1 -1
View File
@@ -107,7 +107,7 @@ export class UpdateCommand {
if (updatedSlashFiles.length > 0) {
// Normalize to forward slashes for cross-platform log consistency
const normalized = updatedSlashFiles.map((p) => FileSystemUtils.toPosixPath(p));
const normalized = updatedSlashFiles.map((p) => p.replace(/\\/g, '/'));
summaryParts.push(`Updated slash commands: ${normalized.join(', ')}`);
}
+3 -4
View File
@@ -5,13 +5,12 @@ import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';
import {
import {
MIN_PURPOSE_LENGTH,
MAX_REQUIREMENT_TEXT_LENGTH,
VALIDATION_MESSAGES
VALIDATION_MESSAGES
} from './constants.js';
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
import { FileSystemUtils } from '../../utils/file-system.js';
export class Validator {
private strictMode: boolean;
@@ -360,7 +359,7 @@ export class Validator {
}
private extractNameFromPath(filePath: string): string {
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
const normalizedPath = filePath.replaceAll('\\', '/');
const parts = normalizedPath.split('/');
// Look for the directory name after 'specs' or 'changes'
-102
View File
@@ -1,102 +0,0 @@
import path from 'path';
import { FileSystemUtils } from './file-system.js';
/**
* Result of validating a change name.
*/
export interface ValidationResult {
valid: boolean;
error?: string;
}
/**
* Validates that a change name follows kebab-case conventions.
*
* Valid names:
* - Start with a lowercase letter
* - Contain only lowercase letters, numbers, and hyphens
* - Do not start or end with a hyphen
* - Do not contain consecutive hyphens
*
* @param name - The change name to validate
* @returns Validation result with `valid: true` or `valid: false` with an error message
*
* @example
* validateChangeName('add-auth') // { valid: true }
* validateChangeName('Add-Auth') // { valid: false, error: '...' }
*/
export function validateChangeName(name: string): ValidationResult {
// Pattern: starts with lowercase letter, followed by lowercase letters/numbers,
// optionally followed by hyphen + lowercase letters/numbers (repeatable)
const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
if (!name) {
return { valid: false, error: 'Change name cannot be empty' };
}
if (!kebabCasePattern.test(name)) {
// Provide specific error messages for common mistakes
if (/[A-Z]/.test(name)) {
return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };
}
if (/\s/.test(name)) {
return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' };
}
if (/_/.test(name)) {
return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' };
}
if (name.startsWith('-')) {
return { valid: false, error: 'Change name cannot start with a hyphen' };
}
if (name.endsWith('-')) {
return { valid: false, error: 'Change name cannot end with a hyphen' };
}
if (/--/.test(name)) {
return { valid: false, error: 'Change name cannot contain consecutive hyphens' };
}
if (/[^a-z0-9-]/.test(name)) {
return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };
}
if (/^[0-9]/.test(name)) {
return { valid: false, error: 'Change name must start with a letter' };
}
return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };
}
return { valid: true };
}
/**
* Creates a new change directory.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @param name - The change name (must be valid kebab-case)
* @throws Error if the change name is invalid
* @throws Error if the change directory already exists
*
* @example
* // Creates openspec/changes/add-auth/
* await createChange('/path/to/project', 'add-auth')
*/
export async function createChange(
projectRoot: string,
name: string
): Promise<void> {
// Validate the name first
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
// Build the change directory path
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
// Check if change already exists
if (await FileSystemUtils.directoryExists(changeDir)) {
throw new Error(`Change '${name}' already exists at ${changeDir}`);
}
// Create the directory (including parent directories if needed)
await FileSystemUtils.createDirectory(changeDir);
}
-8
View File
@@ -42,14 +42,6 @@ function findMarkerIndex(
}
export class FileSystemUtils {
/**
* Converts a path to use forward slashes (POSIX style).
* Essential for cross-platform compatibility with glob libraries like fast-glob.
*/
static toPosixPath(p: string): string {
return p.replace(/\\/g, '/');
}
private static isWindowsBasePath(basePath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
}
+2 -3
View File
@@ -1,3 +1,2 @@
// Shared utilities
export { validateChangeName, createChange } from './change-utils.js';
export type { ValidationResult } from './change-utils.js';
// Shared utilities will be implemented here
export {};
+2 -9
View File
@@ -1,4 +1,4 @@
export type InteractiveOptions = {
type InteractiveOptions = {
/**
* Explicit "disable prompts" flag passed by internal callers.
*/
@@ -9,12 +9,7 @@ export type InteractiveOptions = {
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 {
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
if (typeof value === 'boolean') return value;
return value?.noInteractive === true || value?.interactive === false;
}
@@ -22,8 +17,6 @@ export function resolveNoInteractive(value?: boolean | InteractiveOptions): bool
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;
}
-175
View File
@@ -1,175 +0,0 @@
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);
});
});
-14
View File
@@ -130,18 +130,4 @@ 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
View File
@@ -127,133 +127,6 @@ Then expected result happens`;
expect(updatedContent).toContain('#### Scenario: Basic test');
});
it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => {
const changeName = 'new-spec-with-removed';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'gift-card');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create delta spec with both ADDED and REMOVED requirements
// This simulates refactoring where old fields are removed and new ones are added
const specContent = `# Gift Card - Changes
## ADDED Requirements
### Requirement: Logo and Background Color
The system SHALL support logo and backgroundColor fields for gift cards.
#### Scenario: Display gift card with logo
- **WHEN** a gift card is displayed
- **THEN** it shows the logo and backgroundColor
## REMOVED Requirements
### Requirement: Image Field
### Requirement: Thumbnail Field`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive - should succeed with warning about REMOVED requirements
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify warning was logged about REMOVED requirements being ignored
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).')
);
// Verify spec was created with only ADDED requirements
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md');
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain('# gift-card Specification');
expect(updatedContent).toContain('### Requirement: Logo and Background Color');
expect(updatedContent).toContain('#### Scenario: Display gift card with logo');
// REMOVED requirements should not be in the final spec
expect(updatedContent).not.toContain('### Requirement: Image Field');
expect(updatedContent).not.toContain('### Requirement: Thumbnail Field');
// Verify change was archived successfully
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBeGreaterThan(0);
expect(archives.some(a => a.includes(changeName))).toBe(true);
});
it('should still error on MODIFIED when creating new spec file', async () => {
const changeName = 'new-spec-with-modified';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'new-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create delta spec with MODIFIED requirement (should fail for new spec)
const specContent = `# New Capability - Changes
## ADDED Requirements
### Requirement: New Feature
New feature description.
## MODIFIED Requirements
### Requirement: Existing Feature
Modified content.`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive - should abort with error message (not throw, but log and return)
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify error message mentions MODIFIED not allowed for new specs
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('new-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
);
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
// Verify spec was NOT created
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md');
await expect(fs.access(mainSpecPath)).rejects.toThrow();
// Verify change was NOT archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.some(a => a.includes(changeName))).toBe(false);
});
it('should still error on RENAMED when creating new spec file', async () => {
const changeName = 'new-spec-with-renamed';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'another-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create delta spec with RENAMED requirement (should fail for new spec)
const specContent = `# Another Capability - Changes
## ADDED Requirements
### Requirement: New Feature
New feature description.
## RENAMED Requirements
- FROM: \`### Requirement: Old Name\`
- TO: \`### Requirement: New Name\``;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive - should abort with error message (not throw, but log and return)
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify error message mentions RENAMED not allowed for new specs
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('another-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.')
);
expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');
// Verify spec was NOT created
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md');
await expect(fs.access(mainSpecPath)).rejects.toThrow();
// Verify change was NOT archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.some(a => a.includes(changeName))).toBe(false);
});
it('should throw error if change does not exist', async () => {
await expect(
archiveCommand.execute('non-existent-change', { yes: true })
-268
View File
@@ -1,268 +0,0 @@
import { describe, it, expect } from 'vitest';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
describe('artifact-graph/graph', () => {
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
name: 'test',
version: 1,
artifacts,
});
describe('fromSchema', () => {
it('should create graph from schema object', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getName()).toBe('test');
expect(graph.getVersion()).toBe(1);
});
});
describe('fromYamlContent', () => {
it('should create graph from YAML string', () => {
const yaml = `
name: my-workflow
version: 2
artifacts:
- id: doc
generates: doc.md
description: Documentation
template: templates/doc.md
`;
const graph = ArtifactGraph.fromYamlContent(yaml);
expect(graph.getName()).toBe('my-workflow');
expect(graph.getVersion()).toBe(2);
expect(graph.getArtifact('doc')).toBeDefined();
});
});
describe('getArtifact', () => {
it('should return artifact by ID', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const artifact = graph.getArtifact('proposal');
expect(artifact).toBeDefined();
expect(artifact?.id).toBe('proposal');
expect(artifact?.generates).toBe('proposal.md');
});
it('should return undefined for non-existent ID', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getArtifact('nonexistent')).toBeUndefined();
});
});
describe('getAllArtifacts', () => {
it('should return all artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const artifacts = graph.getAllArtifacts();
expect(artifacts).toHaveLength(3);
expect(artifacts.map(a => a.id).sort()).toEqual(['A', 'B', 'C']);
});
});
describe('getBuildOrder', () => {
it('should return correct order for linear chain A → B → C', () => {
const schema = createSchema([
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['B'] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
expect(order).toEqual(['A', 'B', 'C']);
});
it('should handle diamond dependency correctly', () => {
// A → B, A → C, B → D, C → D
const schema = createSchema([
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
// A must come before B and C; D must come last
expect(order.indexOf('A')).toBeLessThan(order.indexOf('B'));
expect(order.indexOf('A')).toBeLessThan(order.indexOf('C'));
expect(order.indexOf('B')).toBeLessThan(order.indexOf('D'));
expect(order.indexOf('C')).toBeLessThan(order.indexOf('D'));
});
it('should return independent artifacts in stable sorted order', () => {
const schema = createSchema([
{ id: 'Z', generates: 'z.md', description: 'Z', template: 't.md', requires: [] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'M', generates: 'm.md', description: 'M', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
// All independent, should be sorted alphabetically for stability
expect(order).toEqual(['A', 'M', 'Z']);
});
});
describe('getNextArtifacts', () => {
it('should return root artifacts when nothing completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set());
expect(ready.sort()).toEqual(['A', 'C']);
});
it('should include artifact when all deps completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set(['A']));
expect(ready).toEqual(['B']);
});
it('should not include completed artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set(['A', 'B']));
expect(ready).toEqual([]);
});
it('should handle diamond dependency correctly', () => {
// D requires B and C
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Only A completed - B and C ready, D not
expect(graph.getNextArtifacts(new Set(['A'])).sort()).toEqual(['B', 'C']);
// Only B completed (from deps) - C still needed for D
expect(graph.getNextArtifacts(new Set(['A', 'B']))).toEqual(['C']);
// Both B and C completed - D ready
expect(graph.getNextArtifacts(new Set(['A', 'B', 'C']))).toEqual(['D']);
});
});
describe('isComplete', () => {
it('should return true when all artifacts completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.isComplete(new Set(['A', 'B']))).toBe(true);
});
it('should return false when some artifacts incomplete', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.isComplete(new Set(['A']))).toBe(false);
expect(graph.isComplete(new Set())).toBe(false);
});
});
describe('getBlocked', () => {
it('should return empty object when nothing is blocked', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set())).toEqual({});
});
it('should return artifact blocked by single dependency', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set())).toEqual({ B: ['A'] });
});
it('should return artifact blocked by multiple dependencies', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Neither A nor B completed
expect(graph.getBlocked(new Set())).toEqual({ C: ['A', 'B'] });
});
it('should only list unmet dependencies', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// A completed, B not
expect(graph.getBlocked(new Set(['A']))).toEqual({ C: ['B'] });
});
it('should not include completed artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set(['A', 'B']))).toEqual({});
});
});
});
-271
View File
@@ -1,271 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { resolveSchema, listSchemas, SchemaLoadError } from '../../../src/core/artifact-graph/resolver.js';
import { BUILTIN_SCHEMAS } from '../../../src/core/artifact-graph/builtin-schemas.js';
describe('artifact-graph/resolver', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-resolver-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
originalEnv = { ...process.env };
});
afterEach(() => {
process.env = originalEnv;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('resolveSchema', () => {
it('should return built-in spec-driven schema', () => {
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('spec-driven');
expect(schema.version).toBe(1);
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should return built-in tdd schema', () => {
const schema = resolveSchema('tdd');
expect(schema.name).toBe('tdd');
expect(schema.version).toBe(1);
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should strip .yaml extension from name', () => {
const schema1 = resolveSchema('spec-driven');
const schema2 = resolveSchema('spec-driven.yaml');
expect(schema1).toEqual(schema2);
});
it('should strip .yml extension from name', () => {
const schema1 = resolveSchema('spec-driven');
const schema2 = resolveSchema('spec-driven.yml');
expect(schema1).toEqual(schema2);
});
it('should prefer global override over built-in', () => {
// Set up global data dir
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create a custom schema with same name as built-in
const customSchema = `
name: custom-override
version: 99
artifacts:
- id: custom
generates: custom.md
description: Custom artifact
template: templates/custom.md
`;
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), customSchema);
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('custom-override');
expect(schema.version).toBe(99);
});
it('should validate global override and throw on invalid schema', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create an invalid schema (missing required fields)
const invalidSchema = `
name: invalid
version: 1
artifacts:
- id: broken
# missing generates, description, template
`;
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
fs.writeFileSync(schemaPath, invalidSchema);
expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError);
});
it('should include file path in validation error message', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
const invalidSchema = `
name: invalid
version: 1
artifacts:
- id: broken
`;
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
fs.writeFileSync(schemaPath, invalidSchema);
try {
resolveSchema('spec-driven');
expect.fail('Should have thrown');
} catch (e) {
const error = e as SchemaLoadError;
expect(error.message).toContain(schemaPath);
expect(error.schemaPath).toBe(schemaPath);
expect(error.cause).toBeDefined();
}
});
it('should detect cycles in global override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create a schema with cyclic dependencies
const cyclicSchema = `
name: cyclic
version: 1
artifacts:
- id: a
generates: a.md
description: A
template: templates/a.md
requires: [b]
- id: b
generates: b.md
description: B
template: templates/b.md
requires: [a]
`;
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), cyclicSchema);
expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);
});
it('should detect invalid requires references in global override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create a schema with invalid requires reference
const invalidRefSchema = `
name: invalid-ref
version: 1
artifacts:
- id: a
generates: a.md
description: A
template: templates/a.md
requires: [nonexistent]
`;
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), invalidRefSchema);
expect(() => resolveSchema('spec-driven')).toThrow(/does not exist/);
});
it('should throw SchemaLoadError on YAML syntax errors', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create malformed YAML
const malformedYaml = `
name: bad
version: [[[invalid yaml
`;
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
fs.writeFileSync(schemaPath, malformedYaml);
try {
resolveSchema('spec-driven');
expect.fail('Should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(SchemaLoadError);
const error = e as SchemaLoadError;
expect(error.message).toContain('Failed to parse');
expect(error.message).toContain(schemaPath);
}
});
it('should fall back to built-in when global not found', () => {
process.env.XDG_DATA_HOME = tempDir;
// Don't create any global schemas
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('spec-driven');
expect(schema).toEqual(BUILTIN_SCHEMAS['spec-driven']);
});
it('should throw when schema not found', () => {
expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/);
});
it('should list available built-in schemas in error message', () => {
try {
resolveSchema('nonexistent');
expect.fail('Should have thrown');
} catch (e) {
const error = e as Error;
expect(error.message).toContain('spec-driven');
expect(error.message).toContain('tdd');
}
});
it('should mention both global and built-in schemas were checked in not found error', () => {
try {
resolveSchema('nonexistent');
expect.fail('Should have thrown');
} catch (e) {
const error = e as Error;
expect(error.message).toContain('global overrides');
expect(error.message).toContain('built-in');
}
});
});
describe('listSchemas', () => {
it('should list built-in schemas', () => {
const schemas = listSchemas();
expect(schemas).toContain('spec-driven');
expect(schemas).toContain('tdd');
});
it('should include global override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
fs.writeFileSync(path.join(globalSchemaDir, 'custom-workflow.yaml'), 'name: custom');
const schemas = listSchemas();
expect(schemas).toContain('custom-workflow');
expect(schemas).toContain('spec-driven');
});
it('should deduplicate schemas with same name', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Override spec-driven
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), 'name: custom');
const schemas = listSchemas();
// Should only appear once
const count = schemas.filter(s => s === 'spec-driven').length;
expect(count).toBe(1);
});
it('should return sorted list', () => {
const schemas = listSchemas();
const sorted = [...schemas].sort();
expect(schemas).toEqual(sorted);
});
});
});
-207
View File
@@ -1,207 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseSchema, SchemaValidationError } from '../../../src/core/artifact-graph/schema.js';
describe('artifact-graph/schema', () => {
describe('parseSchema', () => {
it('should parse valid schema YAML', () => {
const yaml = `
name: test-schema
version: 1
description: A test schema
artifacts:
- id: proposal
generates: proposal.md
description: Initial proposal
template: templates/proposal.md
requires: []
- id: design
generates: design.md
description: Design document
template: templates/design.md
requires:
- proposal
`;
const schema = parseSchema(yaml);
expect(schema.name).toBe('test-schema');
expect(schema.version).toBe(1);
expect(schema.description).toBe('A test schema');
expect(schema.artifacts).toHaveLength(2);
expect(schema.artifacts[0].id).toBe('proposal');
expect(schema.artifacts[1].requires).toEqual(['proposal']);
});
it('should throw on missing required fields', () => {
const yaml = `
name: test-schema
version: 1
artifacts:
- id: proposal
description: Missing generates and template
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/generates/);
});
it('should throw on missing schema name', () => {
const yaml = `
version: 1
artifacts:
- id: proposal
generates: proposal.md
description: Test
template: templates/proposal.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/name/);
});
it('should throw on invalid version (non-positive)', () => {
const yaml = `
name: test
version: 0
artifacts:
- id: proposal
generates: proposal.md
description: Test
template: templates/proposal.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/positive/);
});
it('should throw on empty artifacts array', () => {
const yaml = `
name: test
version: 1
artifacts: []
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/artifact/i);
});
it('should throw on duplicate artifact IDs', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: proposal
generates: proposal.md
description: First
template: templates/proposal.md
- id: proposal
generates: other.md
description: Duplicate
template: templates/other.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Duplicate artifact ID: proposal/);
});
it('should throw on invalid requires reference', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: design
generates: design.md
description: Design doc
template: templates/design.md
requires:
- nonexistent
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Invalid dependency reference.*nonexistent/);
});
it('should detect self-referencing cycle', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: Self reference
template: templates/a.md
requires:
- A
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
});
it('should detect simple A → B → A cycle', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: A
template: templates/a.md
requires:
- B
- id: B
generates: b.md
description: B
template: templates/b.md
requires:
- A
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
expect(() => parseSchema(yaml)).toThrow(/→/);
});
it('should detect longer A → B → C → A cycle and list all IDs', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: A
template: templates/a.md
requires:
- C
- id: B
generates: b.md
description: B
template: templates/b.md
requires:
- A
- id: C
generates: c.md
description: C
template: templates/c.md
requires:
- B
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
// Should contain all three in the cycle path
const error = (() => {
try {
parseSchema(yaml);
} catch (e) {
return e;
}
})() as Error;
expect(error.message).toMatch(/A.*→.*B|B.*→.*C|C.*→.*A/);
});
it('should allow default empty requires array', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: root
generates: root.md
description: Root artifact
template: templates/root.md
`;
const schema = parseSchema(yaml);
expect(schema.artifacts[0].requires).toEqual([]);
});
});
});
-174
View File
@@ -1,174 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
describe('artifact-graph/state', () => {
let tempDir: string;
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
name: 'test',
version: 1,
artifacts,
});
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-state-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('detectCompleted', () => {
it('should return empty set when changeDir does not exist', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, '/nonexistent/path');
expect(completed.size).toBe(0);
});
it('should return empty set when changeDir is empty', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
});
it('should mark artifact complete when file exists', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create the file
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
});
it('should not mark artifact complete when file does not exist', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Only create proposal.md
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
expect(completed.has('design')).toBe(false);
});
it('should handle nested paths', () => {
const schema = createSchema([
{ id: 'nested', generates: 'docs/design.md', description: 'Nested', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create nested directory and file
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'docs', 'design.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('nested')).toBe(true);
});
it('should detect glob pattern as complete when files exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with files
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-a.md'), 'content');
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-b.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(true);
});
it('should not mark glob pattern complete when directory is empty', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create empty specs directory
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should not mark glob pattern complete when directory does not exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should not mark glob pattern complete when only non-matching files exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with non-matching files
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'readme.txt'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should handle multiple artifacts with mixed completion', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: ['proposal'] },
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: ['proposal'] },
{ id: 'tasks', generates: 'tasks.md', description: 'Tasks', template: 't.md', requires: ['specs', 'design'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create some files
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'auth.md'), 'content');
// design.md and tasks.md do not exist
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
expect(completed.has('specs')).toBe(true);
expect(completed.has('design')).toBe(false);
expect(completed.has('tasks')).toBe(false);
});
});
});
@@ -1,222 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { resolveSchema } from '../../../src/core/artifact-graph/resolver.js';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
import type { BlockedArtifacts } from '../../../src/core/artifact-graph/types.js';
/**
* Normalize BlockedArtifacts for comparison by sorting dependency arrays.
* The order of unmet dependencies is not guaranteed, so we sort for stable assertions.
*/
function normalizeBlocked(blocked: BlockedArtifacts): BlockedArtifacts {
const normalized: BlockedArtifacts = {};
for (const [key, deps] of Object.entries(blocked)) {
normalized[key] = [...deps].sort();
}
return normalized;
}
describe('artifact-graph workflow integration', () => {
let tempDir: string;
beforeEach(() => {
// Use a unique temp directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workflow-test-'));
});
afterEach(() => {
// Clean up temp directory after each test
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('spec-driven workflow', () => {
it('should progress through complete workflow', () => {
// 1. Resolve the real built-in schema
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Verify schema structure
expect(graph.getName()).toBe('spec-driven');
expect(graph.getAllArtifacts()).toHaveLength(4);
// 2. Initial state - nothing complete, only proposal is ready
let completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
expect(graph.isComplete(completed)).toBe(false);
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
specs: ['proposal'],
design: ['proposal'],
tasks: ['design', 'specs'],
});
// 3. Create proposal.md - now specs and design become ready
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal\n\nInitial proposal content.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal']));
expect(graph.getNextArtifacts(completed).sort()).toEqual(['design', 'specs']);
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
tasks: ['design', 'specs'],
});
// 4. Create design.md - specs still needed for tasks
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design\n\nTechnical design content.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design']));
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
expect(graph.getBlocked(completed)).toEqual({
tasks: ['specs'],
});
// 5. Create specs directory with a spec file - tasks becomes ready
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'feature-auth.md'), '# Auth Spec\n\nAuthentication specification.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design', 'specs']));
expect(graph.getNextArtifacts(completed)).toEqual(['tasks']);
expect(graph.getBlocked(completed)).toEqual({});
// 6. Create tasks.md - workflow complete
fs.writeFileSync(path.join(tempDir, 'tasks.md'), '# Tasks\n\n- [ ] Implement feature');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design', 'specs', 'tasks']));
expect(graph.getNextArtifacts(completed)).toEqual([]);
expect(graph.isComplete(completed)).toBe(true);
expect(graph.getBlocked(completed)).toEqual({});
});
it('should handle out-of-order file creation', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Create files in wrong order - design before proposal
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design');
let completed = detectCompleted(graph, tempDir);
// design file exists but it's still marked complete (filesystem-based)
expect(completed).toEqual(new Set(['design']));
// proposal is still the only "ready" artifact since it has no deps
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
// Now create proposal
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design']));
// specs is the only thing ready now (design already done)
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
});
it('should handle multiple spec files in glob pattern', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Complete prerequisites
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
// Create specs directory with multiple files
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'auth.md'), '# Auth');
fs.writeFileSync(path.join(specsDir, 'api.md'), '# API');
fs.writeFileSync(path.join(specsDir, 'database.md'), '# Database');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(true);
});
});
describe('tdd workflow', () => {
it('should progress through complete workflow', () => {
const schema = resolveSchema('tdd');
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getName()).toBe('tdd');
expect(graph.getBuildOrder()).toEqual(['spec', 'tests', 'implementation', 'docs']);
// Initial state
let completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['spec']);
// Create spec
fs.writeFileSync(path.join(tempDir, 'spec.md'), '# Feature Spec');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['tests']);
// Create tests directory with test file
const testsDir = path.join(tempDir, 'tests');
fs.mkdirSync(testsDir, { recursive: true });
fs.writeFileSync(path.join(testsDir, 'feature.test.ts'), 'describe("feature", () => {});');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['implementation']);
// Create src directory with implementation
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'feature.ts'), 'export function feature() {}');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['docs']);
// Create docs
const docsDir = path.join(tempDir, 'docs');
fs.mkdirSync(docsDir, { recursive: true });
fs.writeFileSync(path.join(docsDir, 'feature.md'), '# Feature Documentation');
completed = detectCompleted(graph, tempDir);
expect(graph.isComplete(completed)).toBe(true);
});
});
describe('build order consistency', () => {
it('should return consistent build order across multiple calls', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
const order1 = graph.getBuildOrder();
const order2 = graph.getBuildOrder();
const order3 = graph.getBuildOrder();
expect(order1).toEqual(order2);
expect(order2).toEqual(order3);
});
});
describe('empty and edge cases', () => {
it('should handle empty change directory gracefully', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Directory exists but is empty
const completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
});
it('should handle non-existent change directory', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
const nonExistentDir = path.join(tempDir, 'does-not-exist');
const completed = detectCompleted(graph, nonExistentDir);
expect(completed.size).toBe(0);
});
it('should not count non-matching files in glob directories', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with wrong file types
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'notes.txt'), 'not a markdown file');
fs.writeFileSync(path.join(specsDir, 'data.json'), '{}');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
});
});
-340
View File
@@ -1,340 +0,0 @@
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({});
});
});
});
-11
View File
@@ -90,9 +90,6 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
windowsHide: true,
});
// Prevent child process from keeping the event loop alive
child.unref();
let stdout = '';
let stderr = '';
let timedOut = false;
@@ -116,19 +113,11 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
// Explicitly destroy streams to prevent hanging handles
child.stdout?.destroy();
child.stderr?.destroy();
child.stdin?.destroy();
reject(error);
});
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
// Explicitly destroy streams to prevent hanging handles
child.stdout?.destroy();
child.stderr?.destroy();
child.stdin?.destroy();
resolve({
exitCode: code,
signal,
-176
View File
@@ -1,176 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { randomUUID } from 'crypto';
import { validateChangeName, createChange } from '../../src/utils/change-utils.js';
describe('validateChangeName', () => {
describe('valid names', () => {
it('should accept simple kebab-case name', () => {
const result = validateChangeName('add-auth');
expect(result).toEqual({ valid: true });
});
it('should accept name with multiple segments', () => {
const result = validateChangeName('add-user-auth');
expect(result).toEqual({ valid: true });
});
it('should accept name with numeric suffix', () => {
const result = validateChangeName('add-feature-2');
expect(result).toEqual({ valid: true });
});
it('should accept single word name', () => {
const result = validateChangeName('refactor');
expect(result).toEqual({ valid: true });
});
it('should accept name with numbers in segments', () => {
const result = validateChangeName('upgrade-to-v2');
expect(result).toEqual({ valid: true });
});
});
describe('invalid names - uppercase rejected', () => {
it('should reject name with uppercase letters', () => {
const result = validateChangeName('Add-Auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('lowercase');
});
it('should reject fully uppercase name', () => {
const result = validateChangeName('ADD-AUTH');
expect(result.valid).toBe(false);
expect(result.error).toContain('lowercase');
});
});
describe('invalid names - spaces rejected', () => {
it('should reject name with spaces', () => {
const result = validateChangeName('add auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('spaces');
});
});
describe('invalid names - underscores rejected', () => {
it('should reject name with underscores', () => {
const result = validateChangeName('add_auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('underscores');
});
});
describe('invalid names - special characters rejected', () => {
it('should reject name with exclamation mark', () => {
const result = validateChangeName('add-auth!');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject name with @ symbol', () => {
const result = validateChangeName('add@auth');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('invalid names - leading/trailing hyphens rejected', () => {
it('should reject name with leading hyphen', () => {
const result = validateChangeName('-add-auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('start with a hyphen');
});
it('should reject name with trailing hyphen', () => {
const result = validateChangeName('add-auth-');
expect(result.valid).toBe(false);
expect(result.error).toContain('end with a hyphen');
});
});
describe('invalid names - consecutive hyphens rejected', () => {
it('should reject name with double hyphens', () => {
const result = validateChangeName('add--auth');
expect(result.valid).toBe(false);
expect(result.error).toContain('consecutive hyphens');
});
});
describe('invalid names - empty name rejected', () => {
it('should reject empty string', () => {
const result = validateChangeName('');
expect(result.valid).toBe(false);
expect(result.error).toContain('empty');
});
});
});
describe('createChange', () => {
let testDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('creates directory', () => {
it('should create change directory', async () => {
await createChange(testDir, 'add-auth');
const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth');
const stats = await fs.stat(changeDir);
expect(stats.isDirectory()).toBe(true);
});
});
describe('duplicate change throws error', () => {
it('should throw error if change already exists', async () => {
await createChange(testDir, 'add-auth');
await expect(createChange(testDir, 'add-auth')).rejects.toThrow(
/already exists/
);
});
});
describe('invalid name throws validation error', () => {
it('should throw error for uppercase name', async () => {
await expect(createChange(testDir, 'Add-Auth')).rejects.toThrow(
/lowercase/
);
});
it('should throw error for name with spaces', async () => {
await expect(createChange(testDir, 'add auth')).rejects.toThrow(
/spaces/
);
});
it('should throw error for empty name', async () => {
await expect(createChange(testDir, '')).rejects.toThrow(
/empty/
);
});
});
describe('creates parent directories if needed', () => {
it('should create openspec/changes/ directories if they do not exist', async () => {
const newProjectDir = path.join(testDir, 'new-project');
await fs.mkdir(newProjectDir);
// openspec/changes/ does not exist yet
await createChange(newProjectDir, 'add-auth');
const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth');
const stats = await fs.stat(changeDir);
expect(stats.isDirectory()).toBe(true);
});
});
});
-125
View File
@@ -1,125 +0,0 @@
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);
});
});
});
+1 -2
View File
@@ -20,7 +20,6 @@ export default defineConfig({
]
},
testTimeout: 10000,
hookTimeout: 10000,
teardownTimeout: 3000
hookTimeout: 10000
}
});
-6
View File
@@ -4,9 +4,3 @@ import { ensureCliBuilt } from './test/helpers/run-cli.js';
export async function setup() {
await ensureCliBuilt();
}
// Global teardown to ensure clean exit
export async function teardown() {
// Clear any remaining timers
// This helps prevent hanging handles from keeping the process alive
}