Skip to content

Commit 334b23d

Browse files
committed
feat: implement change creation utilities
Add createChange() and validateChangeName() functions for programmatic change directory creation with kebab-case validation. - createChange(projectRoot, name) creates openspec/changes/<name>/ - validateChangeName() enforces kebab-case naming conventions - Comprehensive test coverage (21 tests)
1 parent 698c5c3 commit 334b23d

File tree

7 files changed

+307
-52
lines changed

7 files changed

+307
-52
lines changed

openspec/changes/add-change-manager/design.md

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ export function validateChangeName(name: string): { valid: boolean; error?: stri
3131

3232
export async function createChange(
3333
projectRoot: string,
34-
name: string,
35-
description?: string
34+
name: string
3635
): Promise<void>
3736
```
3837

@@ -58,21 +57,6 @@ Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--aut
5857
- URL-safe (for future web UI)
5958
- Consistent with existing change naming in repo
6059

61-
### Decision 3: README.md for Change Metadata
62-
63-
**Choice**: `createChange()` generates a `README.md` with name and optional description.
64-
65-
```markdown
66-
# add-auth
67-
68-
Add user authentication system
69-
```
70-
71-
**Why**:
72-
- Human-readable in GitHub/GitLab UI
73-
- Minimal overhead
74-
- Matches existing manual change creation patterns
75-
7660
## File Changes
7761

7862
### New Files

openspec/changes/add-change-manager/proposal.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ There's no programmatic way to create a new change directory. Users must manuall
88
This is error-prone and blocks automation (e.g., Claude commands, scripts).
99

1010
**This proposal adds:**
11-
1. `createChange(name, description?)` - Create change directories programmatically
11+
1. `createChange(projectRoot, name)` - Create change directories programmatically
1212
2. `validateChangeName(name)` - Enforce kebab-case naming conventions
1313

1414
## What Changes
@@ -17,7 +17,7 @@ This is error-prone and blocks automation (e.g., Claude commands, scripts).
1717

1818
| Function | Description |
1919
|----------|-------------|
20-
| `createChange(name, description?)` | Creates `openspec/changes/<name>/` with README.md |
20+
| `createChange(projectRoot, name)` | Creates `openspec/changes/<name>/` directory |
2121
| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` |
2222

2323
### Name Validation Rules

openspec/changes/add-change-manager/specs/change-creation/spec.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,9 @@
33
### Requirement: Change Creation
44
The system SHALL provide a function to create new change directories programmatically.
55

6-
#### Scenario: Create change with name only
6+
#### Scenario: Create change
77
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
88
- **THEN** the system creates `openspec/changes/add-auth/` directory
9-
- **AND** creates a `README.md` file with the change name as title
10-
11-
#### Scenario: Create change with description
12-
- **WHEN** `createChange(projectRoot, 'add-auth', 'Add user authentication')` is called
13-
- **THEN** the system creates `openspec/changes/add-auth/` directory
14-
- **AND** creates a `README.md` file with the change name and description
159

1610
#### Scenario: Duplicate change rejected
1711
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,30 @@
11
## Phase 1: Implement Name Validation
22

3-
- [ ] 1.1 Create `src/utils/change-utils.ts`
4-
- [ ] 1.2 Implement `validateChangeName()` with kebab-case pattern
5-
- [ ] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
6-
- [ ] 1.4 Return `{ valid: boolean; error?: string }`
7-
- [ ] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)
8-
- [ ] 1.6 Add test: uppercase rejected
9-
- [ ] 1.7 Add test: spaces rejected
10-
- [ ] 1.8 Add test: underscores rejected
11-
- [ ] 1.9 Add test: special characters rejected
12-
- [ ] 1.10 Add test: leading/trailing hyphens rejected
13-
- [ ] 1.11 Add test: consecutive hyphens rejected
3+
- [x] 1.1 Create `src/utils/change-utils.ts`
4+
- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern
5+
- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
6+
- [x] 1.4 Return `{ valid: boolean; error?: string }`
7+
- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)
8+
- [x] 1.6 Add test: uppercase rejected
9+
- [x] 1.7 Add test: spaces rejected
10+
- [x] 1.8 Add test: underscores rejected
11+
- [x] 1.9 Add test: special characters rejected
12+
- [x] 1.10 Add test: leading/trailing hyphens rejected
13+
- [x] 1.11 Add test: consecutive hyphens rejected
1414

1515
## Phase 2: Implement Change Creation
1616

17-
- [ ] 2.1 Implement `createChange(projectRoot, name, description?)`
18-
- [ ] 2.2 Validate name before creating
19-
- [ ] 2.3 Create parent directories if needed (`openspec/changes/`)
20-
- [ ] 2.4 Generate README.md with name and optional description
21-
- [ ] 2.5 Throw if change already exists
22-
- [ ] 2.6 Add test: creates directory and README with name only
23-
- [ ] 2.7 Add test: creates directory and README with description
24-
- [ ] 2.8 Add test: duplicate change throws error
25-
- [ ] 2.9 Add test: invalid name throws validation error
26-
- [ ] 2.10 Add test: creates parent directories if needed
17+
- [x] 2.1 Implement `createChange(projectRoot, name)`
18+
- [x] 2.2 Validate name before creating
19+
- [x] 2.3 Create parent directories if needed (`openspec/changes/`)
20+
- [x] 2.4 Throw if change already exists
21+
- [x] 2.5 Add test: creates directory
22+
- [x] 2.6 Add test: duplicate change throws error
23+
- [x] 2.7 Add test: invalid name throws validation error
24+
- [x] 2.8 Add test: creates parent directories if needed
2725

2826
## Phase 3: Integration
2927

30-
- [ ] 3.1 Export functions from `src/utils/index.ts`
31-
- [ ] 3.2 Add JSDoc comments
32-
- [ ] 3.3 Run all tests to verify no regressions
28+
- [x] 3.1 Export functions from `src/utils/index.ts`
29+
- [x] 3.2 Add JSDoc comments
30+
- [x] 3.3 Run all tests to verify no regressions

src/utils/change-utils.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import path from 'path';
2+
import { FileSystemUtils } from './file-system.js';
3+
4+
/**
5+
* Result of validating a change name.
6+
*/
7+
export interface ValidationResult {
8+
valid: boolean;
9+
error?: string;
10+
}
11+
12+
/**
13+
* Validates that a change name follows kebab-case conventions.
14+
*
15+
* Valid names:
16+
* - Start with a lowercase letter
17+
* - Contain only lowercase letters, numbers, and hyphens
18+
* - Do not start or end with a hyphen
19+
* - Do not contain consecutive hyphens
20+
*
21+
* @param name - The change name to validate
22+
* @returns Validation result with `valid: true` or `valid: false` with an error message
23+
*
24+
* @example
25+
* validateChangeName('add-auth') // { valid: true }
26+
* validateChangeName('Add-Auth') // { valid: false, error: '...' }
27+
*/
28+
export function validateChangeName(name: string): ValidationResult {
29+
// Pattern: starts with lowercase letter, followed by lowercase letters/numbers,
30+
// optionally followed by hyphen + lowercase letters/numbers (repeatable)
31+
const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
32+
33+
if (!name) {
34+
return { valid: false, error: 'Change name cannot be empty' };
35+
}
36+
37+
if (!kebabCasePattern.test(name)) {
38+
// Provide specific error messages for common mistakes
39+
if (/[A-Z]/.test(name)) {
40+
return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };
41+
}
42+
if (/\s/.test(name)) {
43+
return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' };
44+
}
45+
if (/_/.test(name)) {
46+
return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' };
47+
}
48+
if (name.startsWith('-')) {
49+
return { valid: false, error: 'Change name cannot start with a hyphen' };
50+
}
51+
if (name.endsWith('-')) {
52+
return { valid: false, error: 'Change name cannot end with a hyphen' };
53+
}
54+
if (/--/.test(name)) {
55+
return { valid: false, error: 'Change name cannot contain consecutive hyphens' };
56+
}
57+
if (/[^a-z0-9-]/.test(name)) {
58+
return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };
59+
}
60+
if (/^[0-9]/.test(name)) {
61+
return { valid: false, error: 'Change name must start with a letter' };
62+
}
63+
64+
return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };
65+
}
66+
67+
return { valid: true };
68+
}
69+
70+
/**
71+
* Creates a new change directory.
72+
*
73+
* @param projectRoot - The root directory of the project (where `openspec/` lives)
74+
* @param name - The change name (must be valid kebab-case)
75+
* @throws Error if the change name is invalid
76+
* @throws Error if the change directory already exists
77+
*
78+
* @example
79+
* // Creates openspec/changes/add-auth/
80+
* await createChange('/path/to/project', 'add-auth')
81+
*/
82+
export async function createChange(
83+
projectRoot: string,
84+
name: string
85+
): Promise<void> {
86+
// Validate the name first
87+
const validation = validateChangeName(name);
88+
if (!validation.valid) {
89+
throw new Error(validation.error);
90+
}
91+
92+
// Build the change directory path
93+
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
94+
95+
// Check if change already exists
96+
if (await FileSystemUtils.directoryExists(changeDir)) {
97+
throw new Error(`Change '${name}' already exists at ${changeDir}`);
98+
}
99+
100+
// Create the directory (including parent directories if needed)
101+
await FileSystemUtils.createDirectory(changeDir);
102+
}

src/utils/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
// Shared utilities will be implemented here
2-
export {};
1+
// Shared utilities
2+
export { validateChangeName, createChange } from './change-utils.js';
3+
export type { ValidationResult } from './change-utils.js';

0 commit comments

Comments
 (0)