diff --git a/docs/artifact_poc.md b/docs/artifact_poc.md index 55020233..3d409e14 100644 --- a/docs/artifact_poc.md +++ b/docs/artifact_poc.md @@ -22,7 +22,7 @@ This system is **not** a workflow engine. It's an **artifact tracker with depend | Term | Definition | Example | |------|------------|---------| | **Change** | A unit of work being planned (feature, refactor, migration) | `openspec/changes/add-auth/` | -| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `schemas/spec-driven.yaml` | +| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `spec-driven.yaml` | | **Artifact** | A node in the graph (a document to create) | `proposal`, `design`, `specs` | | **Template** | Instructions/guidance for creating an artifact | `templates/proposal.md` | @@ -47,25 +47,39 @@ Schemas can vary across multiple dimensions: | Language | `en`, `zh`, `es` | | Custom | `team-alpha`, `experimental` | +### Schema Resolution (XDG Standard) + +Schemas follow the XDG Base Directory Specification with a 2-level resolution: + +``` +1. ${XDG_DATA_HOME}/openspec/schemas/.yaml # Global user override +2. /schemas/.yaml # Built-in defaults +``` + +**Platform-specific paths:** +- Unix/macOS: `~/.local/share/openspec/schemas/` +- Windows: `%LOCALAPPDATA%/openspec/schemas/` +- All platforms: `$XDG_DATA_HOME/openspec/schemas/` (when set) + +**Why XDG?** +- Schemas are workflow definitions (data), not user preferences (config) +- Built-ins baked into package, never auto-copied +- Users customize by creating files in global data dir +- Consistent with modern CLI tooling standards + ### Template Inheritance (2 Levels Max) +Templates also use 2-level resolution (to be implemented in Slice 3): + ``` -.openspec/ -├── templates/ # Shared (Level 1) -│ ├── proposal.md -│ ├── design.md -│ └── specs.md -│ -└── schemas/ - └── tdd/ - ├── schema.yaml - └── templates/ # Overrides (Level 2) - └── tests.md # TDD-specific +1. ${XDG_DATA_HOME}/openspec/schemas//templates/.md # Schema-specific +2. ${XDG_DATA_HOME}/openspec/templates/.md # Shared +3. /templates/.md # Built-in fallback ``` **Rules:** -- Shared templates are the default -- Schema-specific templates override OR add new +- Schema-specific templates override shared templates +- Shared templates override package built-ins - A CLI command shows resolved paths (no guessing) - No inheritance between schemas (copy if you need to diverge) - Max 2 levels - no deeper inheritance chains @@ -90,83 +104,82 @@ The system answers: ## Core Components -### 1. ArtifactGraph +### 1. ArtifactGraph (Slice 1 - COMPLETE) -The dependency graph engine. +The dependency graph engine with XDG-compliant schema resolution. | Responsibility | Approach | |----------------|----------| | Model artifacts as a DAG | Artifact with `requires: string[]` | -| Track completion state | Sets for `completed`, `in_progress`, `failed` | +| Track completion state | `Set` for completed artifacts | | Calculate build order | Kahn's algorithm (topological sort) | | Find ready artifacts | Check if all dependencies are in `completed` set | - -**Key Data Structures:** - -``` -Artifact { - id: string - generates: string // e.g., "proposal.md" or "specs/*.md" - description: string - instruction: string // path to template - requires: string[] // artifact IDs this depends on -} - -ArtifactState { - completed: Set - inProgress: Set - failed: Set -} - -ArtifactGraph { - artifacts: Map -} +| Resolve schemas | XDG global → package built-ins | + +**Key Data Structures (Zod-validated):** + +```typescript +// Zod schemas define types + validation +const ArtifactSchema = z.object({ + id: z.string().min(1), + generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md" + description: z.string(), + template: z.string(), // path to template file + requires: z.array(z.string()).default([]), +}); + +const SchemaYamlSchema = z.object({ + name: z.string().min(1), + version: z.number().int().positive(), + description: z.string().optional(), + artifacts: z.array(ArtifactSchema).min(1), +}); + +// Derived types +type Artifact = z.infer; +type SchemaYaml = z.infer; ``` **Key Methods:** -- `fromYaml(path)` - Load artifact definitions from YAML -- `getNextArtifacts(state)` - Find artifacts ready to create -- `getBuildOrder()` - Topological sort of all artifacts -- `isComplete(state)` - Check if all artifacts done +- `resolveSchema(name)` - Load schema with XDG fallback +- `ArtifactGraph.fromSchema(schema)` - Build graph from schema +- `detectState(graph, changeDir)` - Scan filesystem for completion +- `getNextArtifacts(graph, completed)` - Find artifacts ready to create +- `getBuildOrder(graph)` - Topological sort of all artifacts +- `getBlocked(graph, completed)` - Artifacts with unmet dependencies --- -### 2. ChangeManager +### 2. Change Utilities (Slice 2) -Multi-change orchestration layer. **CLI is fully deterministic** - no "active change" tracking. +Simple utility functions for programmatic change creation. No class, no abstraction layer. | Responsibility | Approach | |----------------|----------| -| CRUD changes | Create dirs under `openspec/changes//` | -| Template fallback | Schema-specific → Shared (2 levels max) | +| Create changes | Create dirs under `openspec/changes//` with README | +| Name validation | Enforce kebab-case naming | **Key Paths:** ``` -.openspec/schemas/ → Schema definitions (artifact graphs) -.openspec/templates/ → Shared instruction templates -openspec/changes// → Change instances with artifacts +openspec/changes// → Change instances with artifacts (project-level) ``` -**Key Methods:** -- `isInitialized()` - Check for `.openspec/` existence -- `listChanges()` - List all changes in `openspec/changes/` -- `createChange(name, description)` - Create new change directory -- `getChangePath(name)` - Get path to a change directory -- `getSchemaPath(schemaName?)` - Find schema with fallback -- `getTemplatePath(artifactId, schemaName?)` - Find template (schema → shared) +**Key Functions** (`src/utils/change-utils.ts`): +- `createChange(projectRoot, name, description?)` - Create new change directory + README +- `validateChangeName(name)` - Validate kebab-case naming, returns `{ valid, error? }` -**Note:** No `getActiveChange()`, `setActiveChange()`, or `resolveChange()` - the agent infers which change from conversation context and passes it explicitly to CLI commands. +**Note:** Existing CLI commands (`ListCommand`, `ChangeCommand`) already handle listing, path resolution, and existence checks. No need to extract that logic - it works fine as-is. --- -### 3. InstructionLoader +### 3. InstructionLoader (Slice 3) -State detection and instruction enrichment. +Template resolution and instruction enrichment. | Responsibility | Approach | |----------------|----------| -| Detect artifact completion | Scan filesystem, support glob patterns | +| Resolve templates | XDG 2-level fallback (schema-specific → shared → built-in) | | Build dynamic context | Gather dependency status, change info | | Enrich templates | Inject context into base templates | | Generate status reports | Formatted markdown with progress | @@ -178,7 +191,7 @@ ChangeState { changeName: string changeDir: string graph: ArtifactGraph - state: ArtifactState + completed: Set // Methods getNextSteps(): string[] @@ -188,28 +201,34 @@ ChangeState { ``` **Key Functions:** +- `getTemplatePath(artifactId, schemaName?)` - Resolve with 2-level fallback - `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point - `getChangeStatus(projectRoot, changeName?)` - Formatted status report -- `resolveTemplatePath(artifactId, schemaName?)` - 2-level fallback --- -### 4. CLI +### 4. CLI (Slice 4) User interface layer. **All commands are deterministic** - require explicit `--change` parameter. -| Command | Function | -|---------|----------| -| `status --change ` | Show change progress | -| `next --change ` | Show artifacts ready to create | -| `instructions --change ` | Get enriched instructions for artifact | -| `list` | List all changes | -| `new ` | Create change | -| `init` | Initialize structure | -| `templates --change ` | Show resolved template paths | +| Command | Function | Status | +|---------|----------|--------| +| `status --change ` | Show change progress (artifact graph) | **NEW** | +| `next --change ` | Show artifacts ready to create | **NEW** | +| `instructions --change ` | Get enriched instructions for artifact | **NEW** | +| `list` | List all changes | EXISTS (`openspec change list`) | +| `new ` | Create change | **NEW** (uses `createChange()`) | +| `init` | Initialize structure | EXISTS (`openspec init`) | +| `templates --change ` | Show resolved template paths | **NEW** | **Note:** Commands that operate on a change require `--change`. Missing parameter → error with list of available changes. Agent infers the change from conversation and passes it explicitly. +**Existing CLI commands** (not part of this slice): +- `openspec change list` / `openspec change show ` / `openspec change validate ` +- `openspec list --changes` / `openspec list --specs` +- `openspec view` (dashboard) +- `openspec init` / `openspec archive ` + --- ### 5. Claude Commands @@ -251,19 +270,19 @@ This works for ANY artifact in ANY schema - no new slash commands needed when sc ┌─────────────────────────────────────────────────────────────┐ │ ORCHESTRATION LAYER │ │ ┌────────────────────┐ ┌──────────────────────────┐ │ -│ │ InstructionLoader │───────▶│ ChangeManager │ │ -│ │ │ uses │ │ │ -│ └─────────┬──────────┘ └──────────────────────────┘ │ +│ │ InstructionLoader │ │ change-utils (Slice 2) │ │ +│ │ (Slice 3) │ │ createChange() │ │ +│ └─────────┬──────────┘ │ validateChangeName() │ │ +│ │ └──────────────────────────┘ │ └────────────┼────────────────────────────────────────────────┘ │ uses ▼ ┌─────────────────────────────────────────────────────────────┐ │ CORE LAYER │ │ ┌──────────────────────────────────────────────────────┐ │ -│ │ ArtifactGraph │ │ +│ │ ArtifactGraph (Slice 1) │ │ │ │ │ │ -│ │ Artifact ←────── ArtifactState │ │ -│ │ (data) (runtime state) │ │ +│ │ Schema Resolution (XDG) ──→ Graph ──→ State Detection│ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ▲ @@ -272,9 +291,10 @@ This works for ANY artifact in ANY schema - no new slash commands needed when sc ┌─────────────────────────────────────────────────────────────┐ │ PERSISTENCE LAYER │ │ ┌──────────────────┐ ┌────────────────────────────────┐ │ -│ │ YAML Config │ │ Filesystem Artifacts │ │ -│ │ - config.yaml │ │ - proposal.md, design.md │ │ -│ │ - schema.yaml │ │ - specs/*.md, tasks.md │ │ +│ │ XDG Schemas │ │ Project Artifacts │ │ +│ │ ~/.local/share/ │ │ openspec/changes// │ │ +│ │ openspec/ │ │ - proposal.md, design.md │ │ +│ │ schemas/ │ │ - specs/*.md, tasks.md │ │ │ └──────────────────┘ └────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` @@ -310,17 +330,29 @@ This separation means: - Agent handles all "smartness" - No config.yaml tracking of "active change" -### 3. Two-Level Template Fallback +### 3. XDG-Compliant Schema Resolution ``` -schema-specific/templates/proposal.md +${XDG_DATA_HOME}/openspec/schemas/.yaml # User override + ↓ (not found) +/schemas/.yaml # Built-in + ↓ (not found) +Error (schema not found) +``` + +### 4. Two-Level Template Fallback (Slice 3) + +``` +${XDG_DATA_HOME}/openspec/schemas//templates/.md # Schema-specific + ↓ (not found) +${XDG_DATA_HOME}/openspec/templates/.md # Shared ↓ (not found) -.openspec/templates/proposal.md (shared) +/templates/.md # Built-in ↓ (not found) Error (no silent fallback to avoid confusion) ``` -### 4. Glob Pattern Support +### 5. Glob Pattern Support `specs/*.md` allows multiple files to satisfy a single artifact: @@ -333,7 +365,7 @@ if (artifact.generates.includes("*")) { } ``` -### 5. Stateless State Detection +### 6. Stateless State Detection Every command re-scans the filesystem. No cached state to corrupt. @@ -376,39 +408,41 @@ Structured as **vertical slices** - each slice is independently testable. --- -### Slice 1: "What's Ready?" (Core Query) +### Slice 1: "What's Ready?" (Core Query) ✅ COMPLETE -**Combines:** Types + Graph + State Detection +**Delivers:** Types + Graph + State Detection + Schema Resolution -``` -Input: schema YAML path + change directory -Output: { - completed: ['proposal'], - ready: ['specs'], - blocked: ['design', 'tasks'], - buildOrder: ['proposal', 'specs', 'design', 'tasks'] -} -``` +**Implementation:** `src/core/artifact-graph/` +- `types.ts` - Zod schemas and derived TypeScript types +- `schema.ts` - YAML parsing with Zod validation +- `graph.ts` - ArtifactGraph class with topological sort +- `state.ts` - Filesystem-based state detection +- `resolver.ts` - XDG-compliant schema resolution +- `builtin-schemas.ts` - Package-bundled default schemas -**Testable behaviors:** -- Parse schema YAML → returns correct artifact graph -- Compute build order (topological sort) → correct ordering -- Empty directory → only root artifacts (no dependencies) are ready -- Directory with `proposal.md` → `specs` becomes ready -- Directory with `specs/foo.md` → glob pattern detected as complete -- All artifacts present → `isComplete()` returns true +**Key decisions made:** +- Zod for schema validation (consistent with project) +- XDG for global schema overrides +- `Set` for completion state (immutable, functional) +- `inProgress` and `failed` states deferred (require external tracking) --- -### Slice 2: "Multi-Change Management" +### Slice 2: "Change Creation Utilities" -**Delivers:** CRUD for changes, path resolution +**Delivers:** Utility functions for programmatic change creation -**Testable behaviors:** -- `createChange('add-auth')` → creates directory + README -- `listChanges()` → returns directory names -- `getChangePath('add-auth')` → returns correct path -- Missing change → clear error message +**Scope:** +- `createChange(projectRoot, name, description?)` → creates directory + README +- `validateChangeName(name)` → kebab-case pattern enforcement + +**Not in scope (already exists in CLI commands):** +- `listChanges()` → exists in `ListCommand` and `ChangeCommand.getActiveChanges()` +- `getChangePath()` → simple `path.join()` inline +- `changeExists()` → simple `fs.access()` inline +- `isInitialized()` → simple directory check inline + +**Why simplified:** Extracting existing CLI logic into a class would require similar refactoring of `SpecCommand` for consistency. The existing code works fine (~15 lines each). Only truly new functionality is `createChange()` + name validation. --- @@ -417,7 +451,7 @@ Output: { **Delivers:** Template resolution + context injection **Testable behaviors:** -- Template fallback: schema-specific → shared → error +- Template fallback: schema-specific → shared → built-in → error - Context injection: completed deps show ✓, missing show ✗ - Output path shown correctly based on change directory @@ -425,10 +459,23 @@ Output: { ### Slice 4: "CLI + Integration" -**Delivers:** Full command interface +**Delivers:** New artifact graph commands (builds on existing CLI) + +**New commands:** +- `status --change ` - Show artifact completion state +- `next --change ` - Show ready-to-create artifacts +- `instructions --change ` - Get enriched template +- `templates --change ` - Show resolved paths +- `new ` - Create change (wrapper for `createChange()`) + +**Already exists (not in scope):** +- `openspec change list/show/validate` - change management +- `openspec list --changes/--specs` - listing +- `openspec view` - dashboard +- `openspec init` - initialization **Testable behaviors:** -- Each command produces expected output +- Each new command produces expected output - Commands compose correctly (status → next → instructions flow) - Error handling for missing changes, invalid artifacts, etc. @@ -437,32 +484,38 @@ Output: { ## Directory Structure ``` -.openspec/ -├── schemas/ # Schema definitions +# Global (XDG paths - user overrides) +~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/) +%LOCALAPPDATA%/openspec/ # Windows +├── schemas/ # Schema overrides +│ └── custom-workflow.yaml # User-defined schema +└── templates/ # Template overrides (Slice 3) + └── proposal.md # Custom proposal template + +# Package (built-in defaults) +/ +├── schemas/ # Built-in schema definitions │ ├── spec-driven.yaml # Default: proposal → specs → design → tasks -│ ├── spec-driven-v2.yaml # Version 2 -│ ├── tdd.yaml # TDD: tests → implementation → docs -│ └── tdd/ -│ └── templates/ # TDD-specific template overrides -│ └── tests.md -│ -└── templates/ # Shared instruction templates +│ └── tdd.yaml # TDD: tests → implementation → docs +└── templates/ # Built-in templates (Slice 3) ├── proposal.md ├── design.md ├── specs.md └── tasks.md +# Project (change instances) openspec/ └── changes/ # Change instances ├── add-auth/ - │ ├── README.md + │ ├── README.md # Auto-generated on creation │ ├── proposal.md # Created artifacts │ ├── design.md │ └── specs/ │ └── *.md - │ - └── refactor-db/ - └── ... + ├── refactor-db/ + │ └── ... + └── archive/ # Completed changes + └── 2025-01-01-add-auth/ .claude/ ├── settings.local.json # Permissions @@ -475,7 +528,8 @@ openspec/ ## Schema YAML Format ```yaml -# .openspec/schemas/spec-driven.yaml +# Built-in: /schemas/spec-driven.yaml +# Or user override: ~/.local/share/openspec/schemas/spec-driven.yaml name: spec-driven version: 1 description: Specification-driven development @@ -484,7 +538,7 @@ artifacts: - id: proposal generates: "proposal.md" description: "Create project proposal document" - template: "proposal.md" # resolves via 2-level fallback + template: "proposal.md" # resolves via 2-level fallback (Slice 3) requires: [] - id: specs @@ -514,17 +568,26 @@ artifacts: ## Summary -| Layer | Component | Responsibility | -|-------|-----------|----------------| -| Core | ArtifactGraph | Pure dependency logic (no I/O) | -| Core | ChangeManager | Multi-change orchestration | -| Core | InstructionLoader | State detection + enrichment | -| Presentation | CLI | Thin command wrapper | -| Integration | Claude Commands | AI assistant glue | +| Layer | Component | Responsibility | Status | +|-------|-----------|----------------|--------| +| Core | ArtifactGraph | Pure dependency logic + XDG schema resolution | ✅ Slice 1 COMPLETE | +| Utils | change-utils | Change creation + name validation only | Slice 2 (new functionality only) | +| Core | InstructionLoader | Template resolution + enrichment | Slice 3 (all new) | +| Presentation | CLI | New artifact graph commands | Slice 4 (new commands only) | +| Integration | Claude Commands | AI assistant glue | Slice 4 | + +**What already exists (not in this proposal):** +- `getActiveChangeIds()` in `src/utils/item-discovery.ts` - list changes +- `ChangeCommand.list/show/validate()` in `src/commands/change.ts` +- `ListCommand.execute()` in `src/core/list.ts` +- `ViewCommand.execute()` in `src/core/view.ts` - dashboard +- `src/core/init.ts` - initialization +- `src/core/archive.ts` - archiving **Key Principles:** - **Filesystem IS the database** - stateless, version-control friendly - **Dependencies are enablers** - show what's possible, don't force order - **Deterministic CLI, inferring agent** - CLI requires explicit `--change`, agent infers from context -- **2-level template inheritance** - shared + override, no deeper +- **XDG-compliant paths** - schemas and templates use standard user data directories +- **2-level inheritance** - user override → package built-in (no deeper) - **Schemas are versioned** - support variations by philosophy, version, language diff --git a/openspec/changes/archive/2025-12-25-add-change-manager/design.md b/openspec/changes/archive/2025-12-25-add-change-manager/design.md new file mode 100644 index 00000000..12e83514 --- /dev/null +++ b/openspec/changes/archive/2025-12-25-add-change-manager/design.md @@ -0,0 +1,74 @@ +## Context + +This is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically. + +**Current state:** No programmatic way to create changes. Users must manually create directories. + +**Proposed state:** Utility functions for change creation with name validation. + +## Goals / Non-Goals + +### Goals +- **Add** `createChange()` function to create change directories +- **Add** `validateChangeName()` function for kebab-case validation +- **Enable** automation (Claude commands, scripts) to create changes + +### Non-Goals +- Refactor existing CLI commands (they work fine) +- Create abstraction layers or manager classes +- Change how `ListCommand` or `ChangeCommand` work + +## Decisions + +### Decision 1: Simple Utility Functions + +**Choice**: Add functions to `src/utils/change-utils.ts` - no class. + +```typescript +// src/utils/change-utils.ts + +export function validateChangeName(name: string): { valid: boolean; error?: string } + +export async function createChange( + projectRoot: string, + name: string +): Promise +``` + +**Why**: +- Simple, no abstraction overhead +- Easy to test +- Easy to import where needed +- Matches existing utility patterns in `src/utils/` + +**Alternatives considered**: +- ChangeManager class: Rejected - over-engineered for 2 functions +- Add to existing command: Rejected - mixes CLI with reusable logic + +### Decision 2: Kebab-Case Validation Pattern + +**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` + +Valid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor` +Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth` + +**Why**: +- Filesystem-safe (no special characters) +- URL-safe (for future web UI) +- Consistent with existing change naming in repo + +## File Changes + +### New Files +- `src/utils/change-utils.ts` - Utility functions +- `src/utils/change-utils.test.ts` - Unit tests + +### Modified Files +- None + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| Function might not cover all use cases | Start simple, extend if needed | +| Naming conflicts with future work | Using clear, specific function names | diff --git a/openspec/changes/archive/2025-12-25-add-change-manager/proposal.md b/openspec/changes/archive/2025-12-25-add-change-manager/proposal.md new file mode 100644 index 00000000..047d46db --- /dev/null +++ b/openspec/changes/archive/2025-12-25-add-change-manager/proposal.md @@ -0,0 +1,45 @@ +## Why + +There's no programmatic way to create a new change directory. Users must manually: +1. Create `openspec/changes//` directory +2. Create a `proposal.md` file +3. Hope they got the naming right + +This is error-prone and blocks automation (e.g., Claude commands, scripts). + +**This proposal adds:** +1. `createChange(projectRoot, name)` - Create change directories programmatically +2. `validateChangeName(name)` - Enforce kebab-case naming conventions + +## What Changes + +### New Utilities + +| Function | Description | +|----------|-------------| +| `createChange(projectRoot, name)` | Creates `openspec/changes//` directory | +| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` | + +### Name Validation Rules + +Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` + +| Valid | Invalid | +|-------|---------| +| `add-auth` | `Add-Auth` (uppercase) | +| `refactor-db` | `add auth` (spaces) | +| `add-feature-2` | `add_auth` (underscores) | +| `refactor` | `-add-auth` (leading hyphen) | + +### Location + +New file: `src/utils/change-utils.ts` + +Simple utility functions - no class, no abstraction layer. + +## Impact + +- **Affected specs**: None +- **Affected code**: None (new utilities only) +- **New files**: `src/utils/change-utils.ts` +- **Breaking changes**: None diff --git a/openspec/changes/archive/2025-12-25-add-change-manager/specs/change-creation/spec.md b/openspec/changes/archive/2025-12-25-add-change-manager/specs/change-creation/spec.md new file mode 100644 index 00000000..447bf78b --- /dev/null +++ b/openspec/changes/archive/2025-12-25-add-change-manager/specs/change-creation/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Change Creation +The system SHALL provide a function to create new change directories programmatically. + +#### Scenario: Create change +- **WHEN** `createChange(projectRoot, 'add-auth')` is called +- **THEN** the system creates `openspec/changes/add-auth/` directory + +#### Scenario: Duplicate change rejected +- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists +- **THEN** the system throws an error indicating the change already exists + +#### Scenario: Creates parent directories if needed +- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist +- **THEN** the system creates the full path including parent directories + +#### Scenario: Invalid change name rejected +- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name +- **THEN** the system throws a validation error + +### Requirement: Change Name Validation +The system SHALL validate change names follow kebab-case conventions. + +#### Scenario: Valid kebab-case name accepted +- **WHEN** a change name like `add-user-auth` is validated +- **THEN** validation returns `{ valid: true }` + +#### Scenario: Numeric suffixes accepted +- **WHEN** a change name like `add-feature-2` is validated +- **THEN** validation returns `{ valid: true }` + +#### Scenario: Single word accepted +- **WHEN** a change name like `refactor` is validated +- **THEN** validation returns `{ valid: true }` + +#### Scenario: Uppercase characters rejected +- **WHEN** a change name like `Add-Auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Spaces rejected +- **WHEN** a change name like `add auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Underscores rejected +- **WHEN** a change name like `add_auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Special characters rejected +- **WHEN** a change name like `add-auth!` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Leading hyphen rejected +- **WHEN** a change name like `-add-auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Trailing hyphen rejected +- **WHEN** a change name like `add-auth-` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Consecutive hyphens rejected +- **WHEN** a change name like `add--auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` diff --git a/openspec/changes/archive/2025-12-25-add-change-manager/tasks.md b/openspec/changes/archive/2025-12-25-add-change-manager/tasks.md new file mode 100644 index 00000000..d07177cd --- /dev/null +++ b/openspec/changes/archive/2025-12-25-add-change-manager/tasks.md @@ -0,0 +1,30 @@ +## Phase 1: Implement Name Validation + +- [x] 1.1 Create `src/utils/change-utils.ts` +- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern +- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` +- [x] 1.4 Return `{ valid: boolean; error?: string }` +- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`) +- [x] 1.6 Add test: uppercase rejected +- [x] 1.7 Add test: spaces rejected +- [x] 1.8 Add test: underscores rejected +- [x] 1.9 Add test: special characters rejected +- [x] 1.10 Add test: leading/trailing hyphens rejected +- [x] 1.11 Add test: consecutive hyphens rejected + +## Phase 2: Implement Change Creation + +- [x] 2.1 Implement `createChange(projectRoot, name)` +- [x] 2.2 Validate name before creating +- [x] 2.3 Create parent directories if needed (`openspec/changes/`) +- [x] 2.4 Throw if change already exists +- [x] 2.5 Add test: creates directory +- [x] 2.6 Add test: duplicate change throws error +- [x] 2.7 Add test: invalid name throws validation error +- [x] 2.8 Add test: creates parent directories if needed + +## Phase 3: Integration + +- [x] 3.1 Export functions from `src/utils/index.ts` +- [x] 3.2 Add JSDoc comments +- [x] 3.3 Run all tests to verify no regressions diff --git a/openspec/specs/change-creation/spec.md b/openspec/specs/change-creation/spec.md new file mode 100644 index 00000000..3e85719f --- /dev/null +++ b/openspec/specs/change-creation/spec.md @@ -0,0 +1,67 @@ +# change-creation Specification + +## Purpose +Provide programmatic utilities for creating and validating OpenSpec change directories. +## Requirements +### Requirement: Change Creation +The system SHALL provide a function to create new change directories programmatically. + +#### Scenario: Create change +- **WHEN** `createChange(projectRoot, 'add-auth')` is called +- **THEN** the system creates `openspec/changes/add-auth/` directory + +#### Scenario: Duplicate change rejected +- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists +- **THEN** the system throws an error indicating the change already exists + +#### Scenario: Creates parent directories if needed +- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist +- **THEN** the system creates the full path including parent directories + +#### Scenario: Invalid change name rejected +- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name +- **THEN** the system throws a validation error + +### Requirement: Change Name Validation +The system SHALL validate change names follow kebab-case conventions. + +#### Scenario: Valid kebab-case name accepted +- **WHEN** a change name like `add-user-auth` is validated +- **THEN** validation returns `{ valid: true }` + +#### Scenario: Numeric suffixes accepted +- **WHEN** a change name like `add-feature-2` is validated +- **THEN** validation returns `{ valid: true }` + +#### Scenario: Single word accepted +- **WHEN** a change name like `refactor` is validated +- **THEN** validation returns `{ valid: true }` + +#### Scenario: Uppercase characters rejected +- **WHEN** a change name like `Add-Auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Spaces rejected +- **WHEN** a change name like `add auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Underscores rejected +- **WHEN** a change name like `add_auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Special characters rejected +- **WHEN** a change name like `add-auth!` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Leading hyphen rejected +- **WHEN** a change name like `-add-auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Trailing hyphen rejected +- **WHEN** a change name like `add-auth-` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + +#### Scenario: Consecutive hyphens rejected +- **WHEN** a change name like `add--auth` is validated +- **THEN** validation returns `{ valid: false, error: "..." }` + diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts new file mode 100644 index 00000000..0fcdc2b7 --- /dev/null +++ b/src/utils/change-utils.ts @@ -0,0 +1,102 @@ +import path from 'path'; +import { FileSystemUtils } from './file-system.js'; + +/** + * Result of validating a change name. + */ +export interface ValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validates that a change name follows kebab-case conventions. + * + * Valid names: + * - Start with a lowercase letter + * - Contain only lowercase letters, numbers, and hyphens + * - Do not start or end with a hyphen + * - Do not contain consecutive hyphens + * + * @param name - The change name to validate + * @returns Validation result with `valid: true` or `valid: false` with an error message + * + * @example + * validateChangeName('add-auth') // { valid: true } + * validateChangeName('Add-Auth') // { valid: false, error: '...' } + */ +export function validateChangeName(name: string): ValidationResult { + // Pattern: starts with lowercase letter, followed by lowercase letters/numbers, + // optionally followed by hyphen + lowercase letters/numbers (repeatable) + const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; + + if (!name) { + return { valid: false, error: 'Change name cannot be empty' }; + } + + if (!kebabCasePattern.test(name)) { + // Provide specific error messages for common mistakes + if (/[A-Z]/.test(name)) { + return { valid: false, error: 'Change name must be lowercase (use kebab-case)' }; + } + if (/\s/.test(name)) { + return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' }; + } + if (/_/.test(name)) { + return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' }; + } + if (name.startsWith('-')) { + return { valid: false, error: 'Change name cannot start with a hyphen' }; + } + if (name.endsWith('-')) { + return { valid: false, error: 'Change name cannot end with a hyphen' }; + } + if (/--/.test(name)) { + return { valid: false, error: 'Change name cannot contain consecutive hyphens' }; + } + if (/[^a-z0-9-]/.test(name)) { + return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' }; + } + if (/^[0-9]/.test(name)) { + return { valid: false, error: 'Change name must start with a letter' }; + } + + return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' }; + } + + return { valid: true }; +} + +/** + * Creates a new change directory. + * + * @param projectRoot - The root directory of the project (where `openspec/` lives) + * @param name - The change name (must be valid kebab-case) + * @throws Error if the change name is invalid + * @throws Error if the change directory already exists + * + * @example + * // Creates openspec/changes/add-auth/ + * await createChange('/path/to/project', 'add-auth') + */ +export async function createChange( + projectRoot: string, + name: string +): Promise { + // Validate the name first + const validation = validateChangeName(name); + if (!validation.valid) { + throw new Error(validation.error); + } + + // Build the change directory path + const changeDir = path.join(projectRoot, 'openspec', 'changes', name); + + // Check if change already exists + if (await FileSystemUtils.directoryExists(changeDir)) { + throw new Error(`Change '${name}' already exists at ${changeDir}`); + } + + // Create the directory (including parent directories if needed) + await FileSystemUtils.createDirectory(changeDir); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index b1cb3e64..46862b54 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ -// Shared utilities will be implemented here -export {}; \ No newline at end of file +// Shared utilities +export { validateChangeName, createChange } from './change-utils.js'; +export type { ValidationResult } from './change-utils.js'; \ No newline at end of file diff --git a/test/utils/change-utils.test.ts b/test/utils/change-utils.test.ts new file mode 100644 index 00000000..1487e7af --- /dev/null +++ b/test/utils/change-utils.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { validateChangeName, createChange } from '../../src/utils/change-utils.js'; + +describe('validateChangeName', () => { + describe('valid names', () => { + it('should accept simple kebab-case name', () => { + const result = validateChangeName('add-auth'); + expect(result).toEqual({ valid: true }); + }); + + it('should accept name with multiple segments', () => { + const result = validateChangeName('add-user-auth'); + expect(result).toEqual({ valid: true }); + }); + + it('should accept name with numeric suffix', () => { + const result = validateChangeName('add-feature-2'); + expect(result).toEqual({ valid: true }); + }); + + it('should accept single word name', () => { + const result = validateChangeName('refactor'); + expect(result).toEqual({ valid: true }); + }); + + it('should accept name with numbers in segments', () => { + const result = validateChangeName('upgrade-to-v2'); + expect(result).toEqual({ valid: true }); + }); + }); + + describe('invalid names - uppercase rejected', () => { + it('should reject name with uppercase letters', () => { + const result = validateChangeName('Add-Auth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('lowercase'); + }); + + it('should reject fully uppercase name', () => { + const result = validateChangeName('ADD-AUTH'); + expect(result.valid).toBe(false); + expect(result.error).toContain('lowercase'); + }); + }); + + describe('invalid names - spaces rejected', () => { + it('should reject name with spaces', () => { + const result = validateChangeName('add auth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('spaces'); + }); + }); + + describe('invalid names - underscores rejected', () => { + it('should reject name with underscores', () => { + const result = validateChangeName('add_auth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('underscores'); + }); + }); + + describe('invalid names - special characters rejected', () => { + it('should reject name with exclamation mark', () => { + const result = validateChangeName('add-auth!'); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should reject name with @ symbol', () => { + const result = validateChangeName('add@auth'); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('invalid names - leading/trailing hyphens rejected', () => { + it('should reject name with leading hyphen', () => { + const result = validateChangeName('-add-auth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('start with a hyphen'); + }); + + it('should reject name with trailing hyphen', () => { + const result = validateChangeName('add-auth-'); + expect(result.valid).toBe(false); + expect(result.error).toContain('end with a hyphen'); + }); + }); + + describe('invalid names - consecutive hyphens rejected', () => { + it('should reject name with double hyphens', () => { + const result = validateChangeName('add--auth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('consecutive hyphens'); + }); + }); + + describe('invalid names - empty name rejected', () => { + it('should reject empty string', () => { + const result = validateChangeName(''); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + }); +}); + +describe('createChange', () => { + let testDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + describe('creates directory', () => { + it('should create change directory', async () => { + await createChange(testDir, 'add-auth'); + + const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth'); + const stats = await fs.stat(changeDir); + expect(stats.isDirectory()).toBe(true); + }); + }); + + describe('duplicate change throws error', () => { + it('should throw error if change already exists', async () => { + await createChange(testDir, 'add-auth'); + + await expect(createChange(testDir, 'add-auth')).rejects.toThrow( + /already exists/ + ); + }); + }); + + describe('invalid name throws validation error', () => { + it('should throw error for uppercase name', async () => { + await expect(createChange(testDir, 'Add-Auth')).rejects.toThrow( + /lowercase/ + ); + }); + + it('should throw error for name with spaces', async () => { + await expect(createChange(testDir, 'add auth')).rejects.toThrow( + /spaces/ + ); + }); + + it('should throw error for empty name', async () => { + await expect(createChange(testDir, '')).rejects.toThrow( + /empty/ + ); + }); + }); + + describe('creates parent directories if needed', () => { + it('should create openspec/changes/ directories if they do not exist', async () => { + const newProjectDir = path.join(testDir, 'new-project'); + await fs.mkdir(newProjectDir); + + // openspec/changes/ does not exist yet + await createChange(newProjectDir, 'add-auth'); + + const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth'); + const stats = await fs.stat(changeDir); + expect(stats.isDirectory()).toBe(true); + }); + }); +});