Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 9a611ef00a Merge branch 'main' into openspec/add-artifact-graph-core 2025-12-25 20:43:03 +11:00
Tabish Bidiwale c51ba91bb6 fix(test): prevent hanging vitest threads after test runs
- Add teardownTimeout (3s) to vitest config for forced cleanup
- Add global teardown function to vitest.setup.ts
- Call child.unref() to prevent child processes from blocking event loop
- Explicitly destroy stdio streams on process close/error
2025-12-25 03:25:46 +11:00
Tabish Bidiwale f7de12bed6 refactor(artifact-graph): adopt zod v4 error message format
Update custom error messages from string format to zod v4 object format
using `{ error: 'message' }` convention.
2025-12-25 03:11:50 +11:00
Tabish Bidiwale c73a6912c2 test(artifact-graph): add workflow integration tests
Add end-to-end integration tests that exercise the full artifact-graph
pipeline: resolveSchema → ArtifactGraph → detectCompleted → queries.

Tests cover:
- Complete spec-driven and tdd workflow progressions
- Out-of-order file creation handling
- Glob pattern matching with multiple files
- Build order consistency
- Edge cases (empty/missing directories, non-matching files)
2025-12-25 03:10:09 +11:00
Tabish Bidiwale 1824297f32 feat(artifact-graph): validate global schema overrides
Global schema overrides are now validated through the same pipeline as
built-in schemas, catching invalid schemas, cyclic dependencies, and
invalid requires references at load time. Added SchemaLoadError for
better error context with file paths.
2025-12-25 02:51:32 +11:00
Tabish Bidiwale 392cf7a8b4 chore: remove experimental artifact-graph-core-v2 folder
Clean up experimental vertical slice proposal that is no longer needed.
2025-12-25 02:34:56 +11:00
Tabish Bidiwale b9d2261bb0 docs(openspec): archive add-artifact-graph-core change
Archive completed change proposal and create artifact-graph spec with
6 requirements covering schema loading, build order, state detection,
ready queries, completion checks, and blocked queries.
2025-12-25 02:34:48 +11:00
Tabish Bidiwale 87e79872b7 test(artifact-graph): add comprehensive test suite
52 tests covering all artifact-graph functionality:

- schema.test.ts: Parsing, validation errors, cycle detection
- graph.test.ts: Build order, ready artifacts, blocked queries
- state.test.ts: File existence, glob patterns, missing directories
- resolver.test.ts: Schema resolution with global overrides
2025-12-25 02:34:39 +11:00
Tabish Bidiwale 8a222ab58e feat(artifact-graph): add core dependency graph module
Implement Slice 1 ("What's Ready?") of the artifact graph system:

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

Key design decisions:
- Filesystem as database (stateless, git-friendly)
- Cycle errors show full path (e.g., "A → B → C → A")
- Deterministic ordering via sorted queues
2025-12-25 02:34:31 +11:00
Tabish Bidiwale 01cc5cec36 feat(core): add getGlobalDataDir for XDG-compliant data directory
Add getGlobalDataDir() function following XDG Base Directory Specification
for storing user data like schema overrides:
- XDG_DATA_HOME takes precedence on all platforms
- Unix/macOS fallback: ~/.local/share/openspec/
- Windows fallback: %LOCALAPPDATA%/openspec/
2025-12-25 02:33:57 +11:00
Tabish Bidiwale a835b4f32b experiment: add vertical slice version of artifact graph change
Creates add-artifact-graph-core-v2 with requirements organized as
vertical slices - each requirement file contains its spec, design
decisions, and tasks bundled together for comparison.
2025-12-24 00:36:51 +11:00
Tabish Bidiwale 5f8cb32dd3 docs: add 2-level schema resolution and built-in schemas
- Add decision for global → built-in schema resolution pattern
- Add resolver.ts for schema lookup logic
- Add built-in schemas directory (spec-driven.yaml, tdd.yaml)
- Add schema resolution tests
- Follows ESLint/Prettier/Git patterns (defaults baked in package)
2025-12-23 23:30:33 +11:00
Tabish Bidiwale e4f32fa571 docs: specify Zod for schema validation in artifact graph proposal
- Add decision section for Zod schema validation in design.md
- Update data structures to show Zod schemas with z.infer<> types
- Update tasks to specify Zod usage for type definitions and parsing
2025-12-23 23:10:54 +11:00
Tabish Bidiwale a94c819e4b proposal: add artifact graph core query system
Add OpenSpec change proposal for Slice 1 of the artifact POC - the core
"What's Ready?" query system. This implements:

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

This is a parallel module that will coexist with the current system.
2025-12-23 23:06:19 +11:00
24 changed files with 2323 additions and 13 deletions
@@ -0,0 +1,197 @@
## Context
This implements "Slice 1: What's Ready?" from the artifact POC analysis. The core insight is using the filesystem as a database - artifact completion is detected by file existence, making the system stateless and version-control friendly.
This module will coexist with the current OpenSpec system as a parallel capability, potentially enabling future migration or integration.
## Goals / Non-Goals
**Goals:**
- Pure dependency graph logic with no side effects
- Stateless state detection (rescan filesystem each query)
- Support glob patterns for multi-file artifacts (e.g., `specs/*.md`)
- Load artifact definitions from YAML schemas
- Calculate topological build order
- Determine "ready" artifacts based on dependency completion
**Non-Goals:**
- CLI commands (Slice 4)
- Multi-change management (Slice 2)
- Template resolution and enrichment (Slice 3)
- Agent integration or Claude commands
- Replacing existing OpenSpec functionality
## Decisions
### Decision: Filesystem as Database
Use file existence for state detection rather than a separate state file.
**Rationale:**
- Stateless - no state corruption possible
- Git-friendly - state derived from committed files
- Simple - no sync issues between state file and actual files
**Alternatives considered:**
- JSON/SQLite state file: More complex, sync issues, not git-friendly
- Git metadata: Too coupled to git, complex implementation
### Decision: Kahn's Algorithm for Topological Sort
Use Kahn's algorithm for computing build order.
**Rationale:**
- Well-understood, O(V+E) complexity
- Naturally detects cycles during execution
- Produces a stable, deterministic order
### Decision: Glob Pattern Support
Support glob patterns like `specs/*.md` in artifact `generates` field.
**Rationale:**
- Allows multiple files to satisfy a single artifact requirement
- Common pattern for spec directories with multiple files
- Uses standard glob syntax
### Decision: Immutable Completed Set
Represent completion state as an immutable Set of completed artifact IDs.
**Rationale:**
- Functional style, easier to reason about
- State derived fresh each query, no mutation needed
- Clear separation between graph structure and runtime state
- Filesystem can only detect binary existence (complete vs not complete)
**Note:** `inProgress` and `failed` states are deferred to future slices. They would require external state tracking (e.g., a status file) since file existence alone cannot distinguish these states.
### Decision: Zod for Schema Validation
Use Zod for validating YAML schema structure and deriving TypeScript types.
**Rationale:**
- Already a project dependency (v4.0.17) used in `src/core/schemas/`
- Type inference via `z.infer<>` - single source of truth for types
- Runtime validation with detailed error messages
- Consistent with existing project patterns (`base.schema.ts`, `config-schema.ts`)
**Alternatives considered:**
- Manual validation: More code, error-prone, no type inference
- JSON Schema: Would require additional dependency, less TypeScript integration
- io-ts: Not already in project, steeper learning curve
### Decision: Two-Level Schema Resolution
Schemas resolve from global user data directory, falling back to package built-ins.
**Resolution order:**
1. `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml` - Global user override
2. `<package>/schemas/<name>.yaml` - Built-in defaults
**Rationale:**
- Follows XDG Base Directory Specification (schemas are data, not config)
- Mirrors existing `getGlobalConfigDir()` pattern in `src/core/global-paths.ts`
- Built-ins baked into package, never auto-copied
- Users customize by creating files in global data dir
- Simple - no project-level overrides (can add later if needed)
**XDG compliance:**
- Uses `XDG_DATA_HOME` env var when set (all platforms)
- Unix/macOS fallback: `~/.local/share/openspec/`
- Windows fallback: `%LOCALAPPDATA%/openspec/`
**Alternatives considered:**
- Project-level overrides: Added complexity, not needed initially
- Auto-copy to user space: Creates drift, harder to update defaults
- Config directory (`XDG_CONFIG_HOME`): Schemas are workflow definitions (data), not user preferences (config)
### Decision: Template Field Parsed But Not Resolved
The `template` field is required in schema YAML for completeness, but template resolution is deferred to Slice 3.
**Rationale:**
- Slice 1 focuses on "What's Ready?" - dependency and completion queries only
- Template paths are validated syntactically (non-empty string) but not resolved
- Keeps Slice 1 focused and independently testable
### Decision: Cycle Error Format
Cycle errors list all artifact IDs in the cycle for easy debugging.
**Format:** `"Cyclic dependency detected: A → B → C → A"`
**Rationale:**
- Shows the full cycle path, not just that a cycle exists
- Actionable - developer can see exactly which artifacts to fix
- Consistent with Kahn's algorithm which naturally identifies cycle participants
## Data Structures
**Zod Schemas (source of truth):**
```typescript
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, 'Artifact ID is required'),
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
description: z.string(),
template: z.string(), // path to template file
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, 'Schema name is required'),
version: z.number().int().positive(),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, 'At least one artifact required'),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
```
**Runtime State (not Zod - internal only):**
```typescript
// Slice 1: Simple completion tracking via filesystem
type CompletedSet = Set<string>;
// Return type for blocked query
interface BlockedArtifacts {
[artifactId: string]: string[]; // artifact → list of unmet dependencies
}
interface ArtifactGraphResult {
completed: string[];
ready: string[];
blocked: BlockedArtifacts;
buildOrder: string[];
}
```
## File Structure
```
src/core/artifact-graph/
├── index.ts # Public exports
├── types.ts # Zod schemas and type definitions
├── graph.ts # ArtifactGraph class
├── state.ts # State detection logic
├── resolver.ts # Schema resolution (global → built-in)
└── schemas/ # Built-in schema definitions (package level)
├── spec-driven.yaml # Default: proposal → specs → design → tasks
└── tdd.yaml # Alternative: tests → implementation → docs
```
**Schema Resolution Paths:**
- Global user override: `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml`
- Package built-in: `src/core/artifact-graph/schemas/<name>.yaml` (bundled with package)
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Glob pattern edge cases | Use well-tested glob library (fast-glob or similar) |
| Cycle detection | Kahn's algorithm naturally fails on cycles; provide clear error |
| Schema evolution | Version field in schema, validate on load |
## Open Questions
None - all questions resolved in Decisions section.
@@ -0,0 +1,18 @@
## Why
The current OpenSpec system relies on conventions and AI inference for artifact ordering. A formal artifact graph with dependency awareness would enable deterministic "what's ready?" queries, making the system more predictable and enabling future features like automated pipeline execution.
## What Changes
- Add `ArtifactGraph` class to model artifacts as a DAG with dependency relationships
- Add `ArtifactState` type to track completion status (completed, in_progress, failed)
- Add filesystem-based state detection using file existence and glob patterns
- Add schema YAML parser to load artifact definitions
- Implement topological sort (Kahn's algorithm) for build order calculation
- Add `getNextArtifacts()` to find artifacts ready for creation
## Impact
- Affected specs: New `artifact-graph` capability
- Affected code: `src/core/artifact-graph/` (new directory)
- No changes to existing functionality - this is a parallel module
@@ -0,0 +1,103 @@
## ADDED Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files.
#### Scenario: Valid schema loaded
- **WHEN** a valid schema YAML file is provided
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
@@ -0,0 +1,61 @@
## 1. Type Definitions
- [x] 1.1 Create `src/core/artifact-graph/types.ts` with Zod schemas (`ArtifactSchema`, `SchemaYamlSchema`) and inferred types via `z.infer<>`
- [x] 1.2 Define `CompletedSet` (Set<string>), `BlockedArtifacts`, and `ArtifactGraphResult` types for runtime state
## 2. Schema Parser
- [x] 2.1 Create `src/core/artifact-graph/schema.ts` with YAML loading and Zod validation via `.safeParse()`
- [x] 2.2 Implement dependency reference validation (ensure `requires` references valid artifact IDs)
- [x] 2.3 Implement duplicate artifact ID detection
- [x] 2.4 Add cycle detection during schema load (error format: "Cyclic dependency detected: A → B → C → A")
## 3. Artifact Graph Core
- [x] 3.1 Create `src/core/artifact-graph/graph.ts` with ArtifactGraph class
- [x] 3.2 Implement `fromYaml(path)` - load graph from schema file
- [x] 3.3 Implement `getBuildOrder()` - topological sort via Kahn's algorithm
- [x] 3.4 Implement `getArtifact(id)` - retrieve single artifact definition
- [x] 3.5 Implement `getAllArtifacts()` - list all artifacts
## 4. State Detection
- [x] 4.1 Create `src/core/artifact-graph/state.ts` with state detection logic
- [x] 4.2 Implement file existence checking for simple paths
- [x] 4.3 Implement glob pattern matching for multi-file artifacts
- [x] 4.4 Implement `detectCompleted(graph, changeDir)` - scan filesystem and return CompletedSet
- [x] 4.5 Handle missing changeDir gracefully (return empty CompletedSet)
## 5. Ready Calculation
- [x] 5.1 Implement `getNextArtifacts(graph, completed)` - find artifacts with all deps completed
- [x] 5.2 Implement `isComplete(graph, completed)` - check if all artifacts done
- [x] 5.3 Implement `getBlocked(graph, completed)` - return BlockedArtifacts map (artifact → unmet deps)
## 6. Schema Resolution
- [x] 6.1 Create `src/core/artifact-graph/resolver.ts` with schema resolution logic
- [x] 6.2 Add `getGlobalDataDir()` to `src/core/global-config.ts` (XDG_DATA_HOME with platform fallbacks)
- [x] 6.3 Implement `resolveSchema(name)` - global (`${XDG_DATA_HOME}/openspec/schemas/`) → built-in fallback
## 7. Built-in Schemas
- [x] 7.1 Create `src/core/artifact-graph/schemas/spec-driven.yaml` (default: proposal → specs → design → tasks)
- [x] 7.2 Create `src/core/artifact-graph/schemas/tdd.yaml` (alternative: tests → implementation → docs)
## 8. Integration
- [x] 8.1 Create `src/core/artifact-graph/index.ts` with public exports
## 9. Testing
- [x] 9.1 Test: Parse valid schema YAML returns correct artifact graph
- [x] 9.2 Test: Parse invalid schema (missing fields) throws descriptive error
- [x] 9.3 Test: Duplicate artifact IDs throws error
- [x] 9.4 Test: Invalid `requires` reference throws error identifying the invalid ID
- [x] 9.5 Test: Cycle in schema throws error listing cycle path (e.g., "A → B → C → A")
- [x] 9.6 Test: Compute build order returns correct topological ordering (linear chain)
- [x] 9.7 Test: Compute build order handles diamond dependencies correctly
- [x] 9.8 Test: Independent artifacts return in stable order
- [x] 9.9 Test: Empty/missing changeDir returns empty CompletedSet
- [x] 9.10 Test: File existence marks artifact as completed
- [x] 9.11 Test: Glob pattern specs/*.md detected as complete when files exist
- [x] 9.12 Test: Glob pattern with empty directory not marked complete
- [x] 9.13 Test: getNextArtifacts returns only root artifacts when nothing completed
- [x] 9.14 Test: getNextArtifacts includes artifact when all deps completed
- [x] 9.15 Test: getBlocked returns artifact with all unmet dependencies listed
- [x] 9.16 Test: isComplete() returns true when all artifacts completed
- [x] 9.17 Test: isComplete() returns false when some artifacts incomplete
- [x] 9.18 Test: Schema resolution finds global override before built-in
- [x] 9.19 Test: Schema resolution falls back to built-in when no global
+107
View File
@@ -0,0 +1,107 @@
# artifact-graph Specification
## Purpose
TBD - created by archiving change add-artifact-graph-core. Update Purpose after archive.
## Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files.
#### Scenario: Valid schema loaded
- **WHEN** a valid schema YAML file is provided
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
+2
View File
@@ -73,7 +73,9 @@
"@inquirer/prompts": "^7.8.0",
"chalk": "^5.5.0",
"commander": "^14.0.0",
"fast-glob": "^3.3.3",
"ora": "^8.2.0",
"yaml": "^2.8.2",
"zod": "^4.0.17"
}
}
+25 -11
View File
@@ -20,9 +20,15 @@ importers:
commander:
specifier: ^14.0.0
version: 14.0.0
fast-glob:
specifier: ^3.3.3
version: 3.3.3
ora:
specifier: ^8.2.0
version: 8.2.0
yaml:
specifier: ^2.8.2
version: 2.8.2
zod:
specifier: ^4.0.17
version: 4.0.17
@@ -47,7 +53,7 @@ importers:
version: 8.50.1(eslint@9.39.2)(typescript@5.9.3)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
packages:
@@ -1576,6 +1582,11 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2202,13 +2213,13 @@ snapshots:
chai: 5.2.1
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0))':
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 7.0.6(@types/node@24.2.0)
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -2239,7 +2250,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -2987,13 +2998,13 @@ snapshots:
dependencies:
punycode: 2.3.1
vite-node@3.2.4(@types/node@24.2.0):
vite-node@3.2.4(@types/node@24.2.0)(yaml@2.8.2):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.0.6(@types/node@24.2.0)
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -3008,7 +3019,7 @@ snapshots:
- tsx
- yaml
vite@7.0.6(@types/node@24.2.0):
vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2):
dependencies:
esbuild: 0.25.8
fdir: 6.4.6(picomatch@4.0.3)
@@ -3019,12 +3030,13 @@ snapshots:
optionalDependencies:
'@types/node': 24.2.0
fsevents: 2.3.3
yaml: 2.8.2
vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4):
vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0))
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -3042,8 +3054,8 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.0.6(@types/node@24.2.0)
vite-node: 3.2.4(@types/node@24.2.0)
vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2)
vite-node: 3.2.4(@types/node@24.2.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.2.0
@@ -3079,6 +3091,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
yaml@2.8.2: {}
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.2: {}
@@ -0,0 +1,84 @@
import type { SchemaYaml } from './types.js';
/**
* Built-in schema definitions.
* These are compiled into the package, avoiding runtime file resolution issues.
*/
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
name: 'spec-driven',
version: 1,
description: 'Default OpenSpec workflow - proposal → specs → design → tasks',
artifacts: [
{
id: 'proposal',
generates: 'proposal.md',
description: 'Initial proposal document outlining the change',
template: 'templates/proposal.md',
requires: [],
},
{
id: 'specs',
generates: 'specs/*.md',
description: 'Detailed specifications for the change',
template: 'templates/spec.md',
requires: ['proposal'],
},
{
id: 'design',
generates: 'design.md',
description: 'Technical design document with implementation details',
template: 'templates/design.md',
requires: ['proposal'],
},
{
id: 'tasks',
generates: 'tasks.md',
description: 'Implementation tasks derived from specs and design',
template: 'templates/tasks.md',
requires: ['specs', 'design'],
},
],
};
export const TDD_SCHEMA: SchemaYaml = {
name: 'tdd',
version: 1,
description: 'Test-driven development workflow - tests → implementation → docs',
artifacts: [
{
id: 'spec',
generates: 'spec.md',
description: 'Feature specification defining requirements',
template: 'templates/spec.md',
requires: [],
},
{
id: 'tests',
generates: 'tests/*.test.ts',
description: 'Test files written before implementation',
template: 'templates/test.md',
requires: ['spec'],
},
{
id: 'implementation',
generates: 'src/*.ts',
description: 'Implementation code to pass the tests',
template: 'templates/implementation.md',
requires: ['tests'],
},
{
id: 'docs',
generates: 'docs/*.md',
description: 'Documentation for the implemented feature',
template: 'templates/docs.md',
requires: ['implementation'],
},
],
};
/** Map of built-in schema names to their definitions */
export const BUILTIN_SCHEMAS: Record<string, SchemaYaml> = {
'spec-driven': SPEC_DRIVEN_SCHEMA,
'tdd': TDD_SCHEMA,
};
+167
View File
@@ -0,0 +1,167 @@
import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './types.js';
import { loadSchema, parseSchema } from './schema.js';
/**
* Represents an artifact dependency graph.
* Provides methods for querying build order, ready artifacts, and completion status.
*/
export class ArtifactGraph {
private artifacts: Map<string, Artifact>;
private schema: SchemaYaml;
private constructor(schema: SchemaYaml) {
this.schema = schema;
this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
}
/**
* Creates an ArtifactGraph from a YAML file path.
*/
static fromYaml(filePath: string): ArtifactGraph {
const schema = loadSchema(filePath);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from YAML content string.
*/
static fromYamlContent(yamlContent: string): ArtifactGraph {
const schema = parseSchema(yamlContent);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from a pre-validated schema object.
*/
static fromSchema(schema: SchemaYaml): ArtifactGraph {
return new ArtifactGraph(schema);
}
/**
* Gets a single artifact by ID.
*/
getArtifact(id: string): Artifact | undefined {
return this.artifacts.get(id);
}
/**
* Gets all artifacts in the graph.
*/
getAllArtifacts(): Artifact[] {
return Array.from(this.artifacts.values());
}
/**
* Gets the schema name.
*/
getName(): string {
return this.schema.name;
}
/**
* Gets the schema version.
*/
getVersion(): number {
return this.schema.version;
}
/**
* Computes the topological build order using Kahn's algorithm.
* Returns artifact IDs in the order they should be built.
*/
getBuildOrder(): string[] {
const inDegree = new Map<string, number>();
const dependents = new Map<string, string[]>();
// Initialize all artifacts
for (const artifact of this.artifacts.values()) {
inDegree.set(artifact.id, artifact.requires.length);
dependents.set(artifact.id, []);
}
// Build reverse adjacency (who depends on whom)
for (const artifact of this.artifacts.values()) {
for (const req of artifact.requires) {
dependents.get(req)!.push(artifact.id);
}
}
// Start with roots (in-degree 0), sorted for determinism
const queue = [...this.artifacts.keys()]
.filter(id => inDegree.get(id) === 0)
.sort();
const result: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
result.push(current);
// Collect newly ready artifacts, then sort before adding
const newlyReady: string[] = [];
for (const dep of dependents.get(current)!) {
const newDegree = inDegree.get(dep)! - 1;
inDegree.set(dep, newDegree);
if (newDegree === 0) {
newlyReady.push(dep);
}
}
queue.push(...newlyReady.sort());
}
return result;
}
/**
* Gets artifacts that are ready to be created (all dependencies completed).
*/
getNextArtifacts(completed: CompletedSet): string[] {
const ready: string[] = [];
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const allDepsCompleted = artifact.requires.every(req => completed.has(req));
if (allDepsCompleted) {
ready.push(artifact.id);
}
}
// Sort for deterministic ordering
return ready.sort();
}
/**
* Checks if all artifacts in the graph are completed.
*/
isComplete(completed: CompletedSet): boolean {
for (const artifact of this.artifacts.values()) {
if (!completed.has(artifact.id)) {
return false;
}
}
return true;
}
/**
* Gets blocked artifacts and their unmet dependencies.
*/
getBlocked(completed: CompletedSet): BlockedArtifacts {
const blocked: BlockedArtifacts = {};
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const unmetDeps = artifact.requires.filter(req => !completed.has(req));
if (unmetDeps.length > 0) {
blocked[artifact.id] = unmetDeps.sort();
}
}
return blocked;
}
}
+24
View File
@@ -0,0 +1,24 @@
// Types
export {
ArtifactSchema,
SchemaYamlSchema,
type Artifact,
type SchemaYaml,
type CompletedSet,
type BlockedArtifacts,
} from './types.js';
// Schema loading and validation
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
// Graph operations
export { ArtifactGraph } from './graph.js';
// State detection
export { detectCompleted } from './state.js';
// Schema resolution
export { resolveSchema, listSchemas } from './resolver.js';
// Built-in schemas
export { BUILTIN_SCHEMAS, SPEC_DRIVEN_SCHEMA, TDD_SCHEMA } from './builtin-schemas.js';
+121
View File
@@ -0,0 +1,121 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getGlobalDataDir } from '../global-config.js';
import { BUILTIN_SCHEMAS } from './builtin-schemas.js';
import { parseSchema, SchemaValidationError } from './schema.js';
import type { SchemaYaml } from './types.js';
/**
* Error thrown when loading a global schema override fails.
*/
export class SchemaLoadError extends Error {
constructor(
message: string,
public readonly schemaPath: string,
public readonly cause?: Error
) {
super(message);
this.name = 'SchemaLoadError';
}
}
/**
* Resolves a schema name to a SchemaYaml object.
*
* Resolution order:
* 1. Global user override: ${XDG_DATA_HOME}/openspec/schemas/<name>.yaml
* 2. Built-in schema
*
* @param name - Schema name (e.g., "spec-driven")
* @returns The resolved schema object
* @throws Error if schema is not found in any location
*/
export function resolveSchema(name: string): SchemaYaml {
// Normalize name (remove .yaml extension if provided)
const normalizedName = name.replace(/\.ya?ml$/, '');
const builtinNames = Object.keys(BUILTIN_SCHEMAS).join(', ');
// 1. Check global user override (returns path if found)
const globalPath = getGlobalSchemaPath(normalizedName);
if (globalPath) {
// User override found - load and validate through the same pipeline as other schemas
let content: string;
try {
content = fs.readFileSync(globalPath, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to read global schema override at '${globalPath}': ${ioError.message}`,
globalPath,
ioError
);
}
try {
return parseSchema(content);
} catch (err) {
if (err instanceof SchemaValidationError) {
// Re-wrap validation errors to include the file path for context
throw new SchemaLoadError(
`Invalid global schema override at '${globalPath}': ${err.message}`,
globalPath,
err
);
}
// Handle unexpected parse errors (e.g., YAML syntax errors)
const parseError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to parse global schema override at '${globalPath}': ${parseError.message}`,
globalPath,
parseError
);
}
}
// 2. Check built-in schemas
const builtin = BUILTIN_SCHEMAS[normalizedName];
if (builtin) {
return builtin;
}
throw new Error(
`Schema '${normalizedName}' not found. Checked global overrides and built-in schemas. Available built-ins: ${builtinNames}`
);
}
/**
* Gets the path to a global user override schema, if it exists.
*/
function getGlobalSchemaPath(name: string): string | null {
const globalDir = path.join(getGlobalDataDir(), 'schemas');
// Check both .yaml and .yml extensions
for (const ext of ['.yaml', '.yml']) {
const schemaPath = path.join(globalDir, `${name}${ext}`);
if (fs.existsSync(schemaPath)) {
return schemaPath;
}
}
return null;
}
/**
* Lists all available schema names.
* Combines built-in and user override schemas.
*/
export function listSchemas(): string[] {
const schemas = new Set<string>(Object.keys(BUILTIN_SCHEMAS));
// Add user override schemas
const globalDir = path.join(getGlobalDataDir(), 'schemas');
if (fs.existsSync(globalDir)) {
for (const file of fs.readdirSync(globalDir)) {
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
schemas.add(file.replace(/\.ya?ml$/, ''));
}
}
}
return Array.from(schemas).sort();
}
+124
View File
@@ -0,0 +1,124 @@
import * as fs from 'node:fs';
import { parse as parseYaml } from 'yaml';
import { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';
export class SchemaValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SchemaValidationError';
}
}
/**
* Loads and validates an artifact schema from a YAML file.
*/
export function loadSchema(filePath: string): SchemaYaml {
const content = fs.readFileSync(filePath, 'utf-8');
return parseSchema(content);
}
/**
* Parses and validates an artifact schema from YAML content.
*/
export function parseSchema(yamlContent: string): SchemaYaml {
const parsed = parseYaml(yamlContent);
// Validate with Zod
const result = SchemaYamlSchema.safeParse(parsed);
if (!result.success) {
const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
throw new SchemaValidationError(`Invalid schema: ${errors}`);
}
const schema = result.data;
// Check for duplicate artifact IDs
validateNoDuplicateIds(schema.artifacts);
// Check that all requires references are valid
validateRequiresReferences(schema.artifacts);
// Check for cycles
validateNoCycles(schema.artifacts);
return schema;
}
/**
* Validates that there are no duplicate artifact IDs.
*/
function validateNoDuplicateIds(artifacts: Artifact[]): void {
const seen = new Set<string>();
for (const artifact of artifacts) {
if (seen.has(artifact.id)) {
throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);
}
seen.add(artifact.id);
}
}
/**
* Validates that all `requires` references point to valid artifact IDs.
*/
function validateRequiresReferences(artifacts: Artifact[]): void {
const validIds = new Set(artifacts.map(a => a.id));
for (const artifact of artifacts) {
for (const req of artifact.requires) {
if (!validIds.has(req)) {
throw new SchemaValidationError(
`Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`
);
}
}
}
}
/**
* Validates that there are no cyclic dependencies.
* Uses DFS to detect cycles and reports the full cycle path.
*/
function validateNoCycles(artifacts: Artifact[]): void {
const artifactMap = new Map(artifacts.map(a => [a.id, a]));
const visited = new Set<string>();
const inStack = new Set<string>();
const parent = new Map<string, string>();
function dfs(id: string): string | null {
visited.add(id);
inStack.add(id);
const artifact = artifactMap.get(id);
if (!artifact) return null;
for (const dep of artifact.requires) {
if (!visited.has(dep)) {
parent.set(dep, id);
const cycle = dfs(dep);
if (cycle) return cycle;
} else if (inStack.has(dep)) {
// Found a cycle - reconstruct the path
const cyclePath = [dep];
let current = id;
while (current !== dep) {
cyclePath.unshift(current);
current = parent.get(current)!;
}
cyclePath.unshift(dep);
return cyclePath.join(' → ');
}
}
inStack.delete(id);
return null;
}
for (const artifact of artifacts) {
if (!visited.has(artifact.id)) {
const cycle = dfs(artifact.id);
if (cycle) {
throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
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';
/**
* 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.
*/
function hasGlobMatches(pattern: string): boolean {
const matches = fg.sync(pattern, { onlyFiles: true });
return matches.length > 0;
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, { error: 'Artifact ID is required' }),
generates: z.string().min(1, { error: 'generates field is required' }),
description: z.string(),
template: z.string().min(1, { error: 'template field is required' }),
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, { error: 'Schema name is required' }),
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
// Runtime state types (not Zod - internal only)
// Slice 1: Simple completion tracking via filesystem
export type CompletedSet = Set<string>;
// Return type for blocked query
export interface BlockedArtifacts {
[artifactId: string]: string[];
}
+32
View File
@@ -5,6 +5,7 @@ import * as os from 'node:os';
// Constants
export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
export const GLOBAL_DATA_DIR_NAME = 'openspec';
// TypeScript interfaces
export interface GlobalConfig {
@@ -45,6 +46,37 @@ export function getGlobalConfigDir(): string {
return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
}
/**
* Gets the global data directory path following XDG Base Directory Specification.
* Used for user data like schema overrides.
*
* - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set
* - Unix/macOS fallback: ~/.local/share/openspec/
* - Windows fallback: %LOCALAPPDATA%/openspec/
*/
export function getGlobalDataDir(): string {
// XDG_DATA_HOME takes precedence on all platforms when explicitly set
const xdgDataHome = process.env.XDG_DATA_HOME;
if (xdgDataHome) {
return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);
}
const platform = os.platform();
if (platform === 'win32') {
// Windows: use %LOCALAPPDATA%
const localAppData = process.env.LOCALAPPDATA;
if (localAppData) {
return path.join(localAppData, GLOBAL_DATA_DIR_NAME);
}
// Fallback for Windows if LOCALAPPDATA is not set
return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);
}
// Unix/macOS fallback: ~/.local/share
return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);
}
/**
* Gets the path to the global config file.
*/
+3 -1
View File
@@ -2,9 +2,11 @@
export {
GLOBAL_CONFIG_DIR_NAME,
GLOBAL_CONFIG_FILE_NAME,
GLOBAL_DATA_DIR_NAME,
type GlobalConfig,
getGlobalConfigDir,
getGlobalConfigPath,
getGlobalConfig,
saveGlobalConfig
saveGlobalConfig,
getGlobalDataDir
} from './global-config.js';
+268
View File
@@ -0,0 +1,268 @@
import { describe, it, expect } from 'vitest';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
describe('artifact-graph/graph', () => {
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
name: 'test',
version: 1,
artifacts,
});
describe('fromSchema', () => {
it('should create graph from schema object', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getName()).toBe('test');
expect(graph.getVersion()).toBe(1);
});
});
describe('fromYamlContent', () => {
it('should create graph from YAML string', () => {
const yaml = `
name: my-workflow
version: 2
artifacts:
- id: doc
generates: doc.md
description: Documentation
template: templates/doc.md
`;
const graph = ArtifactGraph.fromYamlContent(yaml);
expect(graph.getName()).toBe('my-workflow');
expect(graph.getVersion()).toBe(2);
expect(graph.getArtifact('doc')).toBeDefined();
});
});
describe('getArtifact', () => {
it('should return artifact by ID', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const artifact = graph.getArtifact('proposal');
expect(artifact).toBeDefined();
expect(artifact?.id).toBe('proposal');
expect(artifact?.generates).toBe('proposal.md');
});
it('should return undefined for non-existent ID', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getArtifact('nonexistent')).toBeUndefined();
});
});
describe('getAllArtifacts', () => {
it('should return all artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const artifacts = graph.getAllArtifacts();
expect(artifacts).toHaveLength(3);
expect(artifacts.map(a => a.id).sort()).toEqual(['A', 'B', 'C']);
});
});
describe('getBuildOrder', () => {
it('should return correct order for linear chain A → B → C', () => {
const schema = createSchema([
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['B'] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
expect(order).toEqual(['A', 'B', 'C']);
});
it('should handle diamond dependency correctly', () => {
// A → B, A → C, B → D, C → D
const schema = createSchema([
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
// A must come before B and C; D must come last
expect(order.indexOf('A')).toBeLessThan(order.indexOf('B'));
expect(order.indexOf('A')).toBeLessThan(order.indexOf('C'));
expect(order.indexOf('B')).toBeLessThan(order.indexOf('D'));
expect(order.indexOf('C')).toBeLessThan(order.indexOf('D'));
});
it('should return independent artifacts in stable sorted order', () => {
const schema = createSchema([
{ id: 'Z', generates: 'z.md', description: 'Z', template: 't.md', requires: [] },
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'M', generates: 'm.md', description: 'M', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const order = graph.getBuildOrder();
// All independent, should be sorted alphabetically for stability
expect(order).toEqual(['A', 'M', 'Z']);
});
});
describe('getNextArtifacts', () => {
it('should return root artifacts when nothing completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set());
expect(ready.sort()).toEqual(['A', 'C']);
});
it('should include artifact when all deps completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set(['A']));
expect(ready).toEqual(['B']);
});
it('should not include completed artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const ready = graph.getNextArtifacts(new Set(['A', 'B']));
expect(ready).toEqual([]);
});
it('should handle diamond dependency correctly', () => {
// D requires B and C
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },
{ id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Only A completed - B and C ready, D not
expect(graph.getNextArtifacts(new Set(['A'])).sort()).toEqual(['B', 'C']);
// Only B completed (from deps) - C still needed for D
expect(graph.getNextArtifacts(new Set(['A', 'B']))).toEqual(['C']);
// Both B and C completed - D ready
expect(graph.getNextArtifacts(new Set(['A', 'B', 'C']))).toEqual(['D']);
});
});
describe('isComplete', () => {
it('should return true when all artifacts completed', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.isComplete(new Set(['A', 'B']))).toBe(true);
});
it('should return false when some artifacts incomplete', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.isComplete(new Set(['A']))).toBe(false);
expect(graph.isComplete(new Set())).toBe(false);
});
});
describe('getBlocked', () => {
it('should return empty object when nothing is blocked', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set())).toEqual({});
});
it('should return artifact blocked by single dependency', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set())).toEqual({ B: ['A'] });
});
it('should return artifact blocked by multiple dependencies', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Neither A nor B completed
expect(graph.getBlocked(new Set())).toEqual({ C: ['A', 'B'] });
});
it('should only list unmet dependencies', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },
{ id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// A completed, B not
expect(graph.getBlocked(new Set(['A']))).toEqual({ C: ['B'] });
});
it('should not include completed artifacts', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
{ id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getBlocked(new Set(['A', 'B']))).toEqual({});
});
});
});
+271
View File
@@ -0,0 +1,271 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { resolveSchema, listSchemas, SchemaLoadError } from '../../../src/core/artifact-graph/resolver.js';
import { BUILTIN_SCHEMAS } from '../../../src/core/artifact-graph/builtin-schemas.js';
describe('artifact-graph/resolver', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-resolver-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
originalEnv = { ...process.env };
});
afterEach(() => {
process.env = originalEnv;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('resolveSchema', () => {
it('should return built-in spec-driven schema', () => {
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('spec-driven');
expect(schema.version).toBe(1);
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should return built-in tdd schema', () => {
const schema = resolveSchema('tdd');
expect(schema.name).toBe('tdd');
expect(schema.version).toBe(1);
expect(schema.artifacts.length).toBeGreaterThan(0);
});
it('should strip .yaml extension from name', () => {
const schema1 = resolveSchema('spec-driven');
const schema2 = resolveSchema('spec-driven.yaml');
expect(schema1).toEqual(schema2);
});
it('should strip .yml extension from name', () => {
const schema1 = resolveSchema('spec-driven');
const schema2 = resolveSchema('spec-driven.yml');
expect(schema1).toEqual(schema2);
});
it('should prefer global override over built-in', () => {
// Set up global data dir
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create a custom schema with same name as built-in
const customSchema = `
name: custom-override
version: 99
artifacts:
- id: custom
generates: custom.md
description: Custom artifact
template: templates/custom.md
`;
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), customSchema);
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('custom-override');
expect(schema.version).toBe(99);
});
it('should validate global override and throw on invalid schema', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create an invalid schema (missing required fields)
const invalidSchema = `
name: invalid
version: 1
artifacts:
- id: broken
# missing generates, description, template
`;
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
fs.writeFileSync(schemaPath, invalidSchema);
expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError);
});
it('should include file path in validation error message', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
const invalidSchema = `
name: invalid
version: 1
artifacts:
- id: broken
`;
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
fs.writeFileSync(schemaPath, invalidSchema);
try {
resolveSchema('spec-driven');
expect.fail('Should have thrown');
} catch (e) {
const error = e as SchemaLoadError;
expect(error.message).toContain(schemaPath);
expect(error.schemaPath).toBe(schemaPath);
expect(error.cause).toBeDefined();
}
});
it('should detect cycles in global override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create a schema with cyclic dependencies
const cyclicSchema = `
name: cyclic
version: 1
artifacts:
- id: a
generates: a.md
description: A
template: templates/a.md
requires: [b]
- id: b
generates: b.md
description: B
template: templates/b.md
requires: [a]
`;
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), cyclicSchema);
expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);
});
it('should detect invalid requires references in global override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create a schema with invalid requires reference
const invalidRefSchema = `
name: invalid-ref
version: 1
artifacts:
- id: a
generates: a.md
description: A
template: templates/a.md
requires: [nonexistent]
`;
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), invalidRefSchema);
expect(() => resolveSchema('spec-driven')).toThrow(/does not exist/);
});
it('should throw SchemaLoadError on YAML syntax errors', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Create malformed YAML
const malformedYaml = `
name: bad
version: [[[invalid yaml
`;
const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml');
fs.writeFileSync(schemaPath, malformedYaml);
try {
resolveSchema('spec-driven');
expect.fail('Should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(SchemaLoadError);
const error = e as SchemaLoadError;
expect(error.message).toContain('Failed to parse');
expect(error.message).toContain(schemaPath);
}
});
it('should fall back to built-in when global not found', () => {
process.env.XDG_DATA_HOME = tempDir;
// Don't create any global schemas
const schema = resolveSchema('spec-driven');
expect(schema.name).toBe('spec-driven');
expect(schema).toEqual(BUILTIN_SCHEMAS['spec-driven']);
});
it('should throw when schema not found', () => {
expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/);
});
it('should list available built-in schemas in error message', () => {
try {
resolveSchema('nonexistent');
expect.fail('Should have thrown');
} catch (e) {
const error = e as Error;
expect(error.message).toContain('spec-driven');
expect(error.message).toContain('tdd');
}
});
it('should mention both global and built-in schemas were checked in not found error', () => {
try {
resolveSchema('nonexistent');
expect.fail('Should have thrown');
} catch (e) {
const error = e as Error;
expect(error.message).toContain('global overrides');
expect(error.message).toContain('built-in');
}
});
});
describe('listSchemas', () => {
it('should list built-in schemas', () => {
const schemas = listSchemas();
expect(schemas).toContain('spec-driven');
expect(schemas).toContain('tdd');
});
it('should include global override schemas', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
fs.writeFileSync(path.join(globalSchemaDir, 'custom-workflow.yaml'), 'name: custom');
const schemas = listSchemas();
expect(schemas).toContain('custom-workflow');
expect(schemas).toContain('spec-driven');
});
it('should deduplicate schemas with same name', () => {
process.env.XDG_DATA_HOME = tempDir;
const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas');
fs.mkdirSync(globalSchemaDir, { recursive: true });
// Override spec-driven
fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), 'name: custom');
const schemas = listSchemas();
// Should only appear once
const count = schemas.filter(s => s === 'spec-driven').length;
expect(count).toBe(1);
});
it('should return sorted list', () => {
const schemas = listSchemas();
const sorted = [...schemas].sort();
expect(schemas).toEqual(sorted);
});
});
});
+207
View File
@@ -0,0 +1,207 @@
import { describe, it, expect } from 'vitest';
import { parseSchema, SchemaValidationError } from '../../../src/core/artifact-graph/schema.js';
describe('artifact-graph/schema', () => {
describe('parseSchema', () => {
it('should parse valid schema YAML', () => {
const yaml = `
name: test-schema
version: 1
description: A test schema
artifacts:
- id: proposal
generates: proposal.md
description: Initial proposal
template: templates/proposal.md
requires: []
- id: design
generates: design.md
description: Design document
template: templates/design.md
requires:
- proposal
`;
const schema = parseSchema(yaml);
expect(schema.name).toBe('test-schema');
expect(schema.version).toBe(1);
expect(schema.description).toBe('A test schema');
expect(schema.artifacts).toHaveLength(2);
expect(schema.artifacts[0].id).toBe('proposal');
expect(schema.artifacts[1].requires).toEqual(['proposal']);
});
it('should throw on missing required fields', () => {
const yaml = `
name: test-schema
version: 1
artifacts:
- id: proposal
description: Missing generates and template
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/generates/);
});
it('should throw on missing schema name', () => {
const yaml = `
version: 1
artifacts:
- id: proposal
generates: proposal.md
description: Test
template: templates/proposal.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/name/);
});
it('should throw on invalid version (non-positive)', () => {
const yaml = `
name: test
version: 0
artifacts:
- id: proposal
generates: proposal.md
description: Test
template: templates/proposal.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/positive/);
});
it('should throw on empty artifacts array', () => {
const yaml = `
name: test
version: 1
artifacts: []
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/artifact/i);
});
it('should throw on duplicate artifact IDs', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: proposal
generates: proposal.md
description: First
template: templates/proposal.md
- id: proposal
generates: other.md
description: Duplicate
template: templates/other.md
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Duplicate artifact ID: proposal/);
});
it('should throw on invalid requires reference', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: design
generates: design.md
description: Design doc
template: templates/design.md
requires:
- nonexistent
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Invalid dependency reference.*nonexistent/);
});
it('should detect self-referencing cycle', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: Self reference
template: templates/a.md
requires:
- A
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
});
it('should detect simple A → B → A cycle', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: A
template: templates/a.md
requires:
- B
- id: B
generates: b.md
description: B
template: templates/b.md
requires:
- A
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
expect(() => parseSchema(yaml)).toThrow(/→/);
});
it('should detect longer A → B → C → A cycle and list all IDs', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: A
generates: a.md
description: A
template: templates/a.md
requires:
- C
- id: B
generates: b.md
description: B
template: templates/b.md
requires:
- A
- id: C
generates: c.md
description: C
template: templates/c.md
requires:
- B
`;
expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);
expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);
// Should contain all three in the cycle path
const error = (() => {
try {
parseSchema(yaml);
} catch (e) {
return e;
}
})() as Error;
expect(error.message).toMatch(/A.*→.*B|B.*→.*C|C.*→.*A/);
});
it('should allow default empty requires array', () => {
const yaml = `
name: test
version: 1
artifacts:
- id: root
generates: root.md
description: Root artifact
template: templates/root.md
`;
const schema = parseSchema(yaml);
expect(schema.artifacts[0].requires).toEqual([]);
});
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';
describe('artifact-graph/state', () => {
let tempDir: string;
const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({
name: 'test',
version: 1,
artifacts,
});
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-state-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('detectCompleted', () => {
it('should return empty set when changeDir does not exist', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, '/nonexistent/path');
expect(completed.size).toBe(0);
});
it('should return empty set when changeDir is empty', () => {
const schema = createSchema([
{ id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
});
it('should mark artifact complete when file exists', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create the file
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
});
it('should not mark artifact complete when file does not exist', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Only create proposal.md
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
expect(completed.has('design')).toBe(false);
});
it('should handle nested paths', () => {
const schema = createSchema([
{ id: 'nested', generates: 'docs/design.md', description: 'Nested', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create nested directory and file
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'docs', 'design.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('nested')).toBe(true);
});
it('should detect glob pattern as complete when files exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with files
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-a.md'), 'content');
fs.writeFileSync(path.join(tempDir, 'specs', 'feature-b.md'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(true);
});
it('should not mark glob pattern complete when directory is empty', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create empty specs directory
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should not mark glob pattern complete when directory does not exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should not mark glob pattern complete when only non-matching files exist', () => {
const schema = createSchema([
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with non-matching files
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'readme.txt'), 'content');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
it('should handle multiple artifacts with mixed completion', () => {
const schema = createSchema([
{ id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },
{ id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: ['proposal'] },
{ id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: ['proposal'] },
{ id: 'tasks', generates: 'tasks.md', description: 'Tasks', template: 't.md', requires: ['specs', 'design'] },
]);
const graph = ArtifactGraph.fromSchema(schema);
// Create some files
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'specs', 'auth.md'), 'content');
// design.md and tasks.md do not exist
const completed = detectCompleted(graph, tempDir);
expect(completed.has('proposal')).toBe(true);
expect(completed.has('specs')).toBe(true);
expect(completed.has('design')).toBe(false);
expect(completed.has('tasks')).toBe(false);
});
});
});
@@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { resolveSchema } from '../../../src/core/artifact-graph/resolver.js';
import { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';
import { detectCompleted } from '../../../src/core/artifact-graph/state.js';
import type { BlockedArtifacts } from '../../../src/core/artifact-graph/types.js';
/**
* Normalize BlockedArtifacts for comparison by sorting dependency arrays.
* The order of unmet dependencies is not guaranteed, so we sort for stable assertions.
*/
function normalizeBlocked(blocked: BlockedArtifacts): BlockedArtifacts {
const normalized: BlockedArtifacts = {};
for (const [key, deps] of Object.entries(blocked)) {
normalized[key] = [...deps].sort();
}
return normalized;
}
describe('artifact-graph workflow integration', () => {
let tempDir: string;
beforeEach(() => {
// Use a unique temp directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workflow-test-'));
});
afterEach(() => {
// Clean up temp directory after each test
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('spec-driven workflow', () => {
it('should progress through complete workflow', () => {
// 1. Resolve the real built-in schema
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Verify schema structure
expect(graph.getName()).toBe('spec-driven');
expect(graph.getAllArtifacts()).toHaveLength(4);
// 2. Initial state - nothing complete, only proposal is ready
let completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
expect(graph.isComplete(completed)).toBe(false);
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
specs: ['proposal'],
design: ['proposal'],
tasks: ['design', 'specs'],
});
// 3. Create proposal.md - now specs and design become ready
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal\n\nInitial proposal content.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal']));
expect(graph.getNextArtifacts(completed).sort()).toEqual(['design', 'specs']);
expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({
tasks: ['design', 'specs'],
});
// 4. Create design.md - specs still needed for tasks
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design\n\nTechnical design content.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design']));
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
expect(graph.getBlocked(completed)).toEqual({
tasks: ['specs'],
});
// 5. Create specs directory with a spec file - tasks becomes ready
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'feature-auth.md'), '# Auth Spec\n\nAuthentication specification.');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design', 'specs']));
expect(graph.getNextArtifacts(completed)).toEqual(['tasks']);
expect(graph.getBlocked(completed)).toEqual({});
// 6. Create tasks.md - workflow complete
fs.writeFileSync(path.join(tempDir, 'tasks.md'), '# Tasks\n\n- [ ] Implement feature');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design', 'specs', 'tasks']));
expect(graph.getNextArtifacts(completed)).toEqual([]);
expect(graph.isComplete(completed)).toBe(true);
expect(graph.getBlocked(completed)).toEqual({});
});
it('should handle out-of-order file creation', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Create files in wrong order - design before proposal
fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design');
let completed = detectCompleted(graph, tempDir);
// design file exists but it's still marked complete (filesystem-based)
expect(completed).toEqual(new Set(['design']));
// proposal is still the only "ready" artifact since it has no deps
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
// Now create proposal
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
completed = detectCompleted(graph, tempDir);
expect(completed).toEqual(new Set(['proposal', 'design']));
// specs is the only thing ready now (design already done)
expect(graph.getNextArtifacts(completed)).toEqual(['specs']);
});
it('should handle multiple spec files in glob pattern', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Complete prerequisites
fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');
// Create specs directory with multiple files
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'auth.md'), '# Auth');
fs.writeFileSync(path.join(specsDir, 'api.md'), '# API');
fs.writeFileSync(path.join(specsDir, 'database.md'), '# Database');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(true);
});
});
describe('tdd workflow', () => {
it('should progress through complete workflow', () => {
const schema = resolveSchema('tdd');
const graph = ArtifactGraph.fromSchema(schema);
expect(graph.getName()).toBe('tdd');
expect(graph.getBuildOrder()).toEqual(['spec', 'tests', 'implementation', 'docs']);
// Initial state
let completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['spec']);
// Create spec
fs.writeFileSync(path.join(tempDir, 'spec.md'), '# Feature Spec');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['tests']);
// Create tests directory with test file
const testsDir = path.join(tempDir, 'tests');
fs.mkdirSync(testsDir, { recursive: true });
fs.writeFileSync(path.join(testsDir, 'feature.test.ts'), 'describe("feature", () => {});');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['implementation']);
// Create src directory with implementation
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'feature.ts'), 'export function feature() {}');
completed = detectCompleted(graph, tempDir);
expect(graph.getNextArtifacts(completed)).toEqual(['docs']);
// Create docs
const docsDir = path.join(tempDir, 'docs');
fs.mkdirSync(docsDir, { recursive: true });
fs.writeFileSync(path.join(docsDir, 'feature.md'), '# Feature Documentation');
completed = detectCompleted(graph, tempDir);
expect(graph.isComplete(completed)).toBe(true);
});
});
describe('build order consistency', () => {
it('should return consistent build order across multiple calls', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
const order1 = graph.getBuildOrder();
const order2 = graph.getBuildOrder();
const order3 = graph.getBuildOrder();
expect(order1).toEqual(order2);
expect(order2).toEqual(order3);
});
});
describe('empty and edge cases', () => {
it('should handle empty change directory gracefully', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Directory exists but is empty
const completed = detectCompleted(graph, tempDir);
expect(completed.size).toBe(0);
expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);
});
it('should handle non-existent change directory', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
const nonExistentDir = path.join(tempDir, 'does-not-exist');
const completed = detectCompleted(graph, nonExistentDir);
expect(completed.size).toBe(0);
});
it('should not count non-matching files in glob directories', () => {
const schema = resolveSchema('spec-driven');
const graph = ArtifactGraph.fromSchema(schema);
// Create specs directory with wrong file types
const specsDir = path.join(tempDir, 'specs');
fs.mkdirSync(specsDir, { recursive: true });
fs.writeFileSync(path.join(specsDir, 'notes.txt'), 'not a markdown file');
fs.writeFileSync(path.join(specsDir, 'data.json'), '{}');
const completed = detectCompleted(graph, tempDir);
expect(completed.has('specs')).toBe(false);
});
});
});
+11
View File
@@ -90,6 +90,9 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
windowsHide: true,
});
// Prevent child process from keeping the event loop alive
child.unref();
let stdout = '';
let stderr = '';
let timedOut = false;
@@ -113,11 +116,19 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}):
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
// Explicitly destroy streams to prevent hanging handles
child.stdout?.destroy();
child.stderr?.destroy();
child.stdin?.destroy();
reject(error);
});
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
// Explicitly destroy streams to prevent hanging handles
child.stdout?.destroy();
child.stderr?.destroy();
child.stdin?.destroy();
resolve({
exitCode: code,
signal,
+2 -1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
]
},
testTimeout: 10000,
hookTimeout: 10000
hookTimeout: 10000,
teardownTimeout: 3000
}
});
+6
View File
@@ -4,3 +4,9 @@ import { ensureCliBuilt } from './test/helpers/run-cli.js';
export async function setup() {
await ensureCliBuilt();
}
// Global teardown to ensure clean exit
export async function teardown() {
// Clear any remaining timers
// This helps prevent hanging handles from keeping the process alive
}