diff --git a/openspec/changes/archive/2025-12-24-add-artifact-graph-core/design.md b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/design.md new file mode 100644 index 00000000..0c1d49a0 --- /dev/null +++ b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/design.md @@ -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/.yaml` - Global user override +2. `/schemas/.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; +export type SchemaYaml = z.infer; +``` + +**Runtime State (not Zod - internal only):** + +```typescript +// Slice 1: Simple completion tracking via filesystem +type CompletedSet = Set; + +// 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/.yaml` +- Package built-in: `src/core/artifact-graph/schemas/.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. diff --git a/openspec/changes/archive/2025-12-24-add-artifact-graph-core/proposal.md b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/proposal.md new file mode 100644 index 00000000..393d3311 --- /dev/null +++ b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/proposal.md @@ -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 diff --git a/openspec/changes/archive/2025-12-24-add-artifact-graph-core/specs/artifact-graph/spec.md b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/specs/artifact-graph/spec.md new file mode 100644 index 00000000..bc5898f5 --- /dev/null +++ b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/specs/artifact-graph/spec.md @@ -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'] }` diff --git a/openspec/changes/archive/2025-12-24-add-artifact-graph-core/tasks.md b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/tasks.md new file mode 100644 index 00000000..125f186c --- /dev/null +++ b/openspec/changes/archive/2025-12-24-add-artifact-graph-core/tasks.md @@ -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), `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 diff --git a/openspec/specs/artifact-graph/spec.md b/openspec/specs/artifact-graph/spec.md new file mode 100644 index 00000000..19b0d0bf --- /dev/null +++ b/openspec/specs/artifact-graph/spec.md @@ -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'] }` + diff --git a/package.json b/package.json index 486873d5..0c7c915e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6661eed5..032d0d88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/core/artifact-graph/builtin-schemas.ts b/src/core/artifact-graph/builtin-schemas.ts new file mode 100644 index 00000000..bd4fd4c3 --- /dev/null +++ b/src/core/artifact-graph/builtin-schemas.ts @@ -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 = { + 'spec-driven': SPEC_DRIVEN_SCHEMA, + 'tdd': TDD_SCHEMA, +}; diff --git a/src/core/artifact-graph/graph.ts b/src/core/artifact-graph/graph.ts new file mode 100644 index 00000000..3f960e60 --- /dev/null +++ b/src/core/artifact-graph/graph.ts @@ -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; + 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(); + const dependents = new Map(); + + // 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; + } +} diff --git a/src/core/artifact-graph/index.ts b/src/core/artifact-graph/index.ts new file mode 100644 index 00000000..9b78d797 --- /dev/null +++ b/src/core/artifact-graph/index.ts @@ -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'; diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts new file mode 100644 index 00000000..1488c7ed --- /dev/null +++ b/src/core/artifact-graph/resolver.ts @@ -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/.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(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(); +} diff --git a/src/core/artifact-graph/schema.ts b/src/core/artifact-graph/schema.ts new file mode 100644 index 00000000..6371745e --- /dev/null +++ b/src/core/artifact-graph/schema.ts @@ -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(); + 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(); + const inStack = new Set(); + const parent = new Map(); + + 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}`); + } + } + } +} diff --git a/src/core/artifact-graph/state.ts b/src/core/artifact-graph/state.ts new file mode 100644 index 00000000..449bb723 --- /dev/null +++ b/src/core/artifact-graph/state.ts @@ -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(); + + // 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; +} diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts new file mode 100644 index 00000000..1758b27e --- /dev/null +++ b/src/core/artifact-graph/types.ts @@ -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; +export type SchemaYaml = z.infer; + +// Runtime state types (not Zod - internal only) + +// Slice 1: Simple completion tracking via filesystem +export type CompletedSet = Set; + +// Return type for blocked query +export interface BlockedArtifacts { + [artifactId: string]: string[]; +} + diff --git a/src/core/global-config.ts b/src/core/global-config.ts index b690113a..271ca5a6 100644 --- a/src/core/global-config.ts +++ b/src/core/global-config.ts @@ -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. */ diff --git a/src/core/index.ts b/src/core/index.ts index 22afd94d..e8677090 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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'; \ No newline at end of file diff --git a/test/core/artifact-graph/graph.test.ts b/test/core/artifact-graph/graph.test.ts new file mode 100644 index 00000000..56020754 --- /dev/null +++ b/test/core/artifact-graph/graph.test.ts @@ -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({}); + }); + }); +}); diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts new file mode 100644 index 00000000..6894acfb --- /dev/null +++ b/test/core/artifact-graph/resolver.test.ts @@ -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); + }); + }); +}); diff --git a/test/core/artifact-graph/schema.test.ts b/test/core/artifact-graph/schema.test.ts new file mode 100644 index 00000000..069216a3 --- /dev/null +++ b/test/core/artifact-graph/schema.test.ts @@ -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([]); + }); + }); +}); diff --git a/test/core/artifact-graph/state.test.ts b/test/core/artifact-graph/state.test.ts new file mode 100644 index 00000000..758a7675 --- /dev/null +++ b/test/core/artifact-graph/state.test.ts @@ -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); + }); + }); +}); diff --git a/test/core/artifact-graph/workflow.integration.test.ts b/test/core/artifact-graph/workflow.integration.test.ts new file mode 100644 index 00000000..337c76aa --- /dev/null +++ b/test/core/artifact-graph/workflow.integration.test.ts @@ -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); + }); + }); +}); diff --git a/test/helpers/run-cli.ts b/test/helpers/run-cli.ts index c33f2ff1..69d67df7 100644 --- a/test/helpers/run-cli.ts +++ b/test/helpers/run-cli.ts @@ -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, diff --git a/vitest.config.ts b/vitest.config.ts index 2d6f6588..88c06dd8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ ] }, testTimeout: 10000, - hookTimeout: 10000 + hookTimeout: 10000, + teardownTimeout: 3000 } }); diff --git a/vitest.setup.ts b/vitest.setup.ts index 1547dbfd..3ffc2c60 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -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 +}