@fission-ai/openspec
Version:
AI-native system for spec-driven development
121 lines • 4.96 kB
JavaScript
import path from 'path';
import { FileSystemUtils } from './file-system.js';
import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
import { readProjectConfig } from '../core/project-config.js';
const DEFAULT_SCHEMA = 'spec-driven';
/**
* 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) {
// 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 with metadata file.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @param name - The change name (must be valid kebab-case)
* @param options - Optional settings for the change
* @throws Error if the change name is invalid
* @throws Error if the schema name is invalid
* @throws Error if the change directory already exists
*
* @returns Result containing the resolved schema name
*
* @example
* // Creates openspec/changes/add-auth/ with default schema
* const result = await createChange('/path/to/project', 'add-auth')
* console.log(result.schema) // 'spec-driven' or value from config
*
* @example
* // Creates openspec/changes/add-auth/ with custom schema
* const result = await createChange('/path/to/project', 'add-auth', { schema: 'my-workflow' })
* console.log(result.schema) // 'my-workflow'
*/
export async function createChange(projectRoot, name, options = {}) {
// Validate the name first
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
// Determine schema: explicit option → project config → hardcoded default
let schemaName;
if (options.schema) {
schemaName = options.schema;
}
else {
// Try to read from project config
try {
const config = readProjectConfig(projectRoot);
schemaName = config?.schema ?? DEFAULT_SCHEMA;
}
catch {
// If config read fails, use default
schemaName = DEFAULT_SCHEMA;
}
}
// Validate the resolved schema
validateSchemaName(schemaName, projectRoot);
// 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);
// Write metadata file with schema and creation date
const today = new Date().toISOString().split('T')[0];
writeChangeMetadata(changeDir, {
schema: schemaName,
created: today,
}, projectRoot);
return { schema: schemaName };
}
//# sourceMappingURL=change-utils.js.map