@fission-ai/openspec
Version:
AI-native system for spec-driven development
223 lines • 9.55 kB
JavaScript
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { parse as parseYaml } from 'yaml';
import { z } from 'zod';
/**
* Zod schema for project configuration.
*
* Purpose:
* 1. Documentation - clearly defines the config file structure
* 2. Type safety - TypeScript infers ProjectConfig type from schema
* 3. Runtime validation - uses safeParse() for resilient field-by-field validation
*
* Why Zod over manual validation:
* - Helps understand OpenSpec's data interfaces at a glance
* - Single source of truth for type and validation
* - Consistent with other OpenSpec schemas
*/
export const ProjectConfigSchema = z.object({
// Required: which schema to use (e.g., "spec-driven", or project-local schema name)
schema: z
.string()
.min(1)
.describe('The workflow schema to use (e.g., "spec-driven")'),
// Optional: project context (injected into all artifact instructions)
// Max size: 50KB (enforced during parsing)
context: z
.string()
.optional()
.describe('Project context injected into all artifact instructions'),
// Optional: per-artifact rules (additive to schema's built-in guidance)
rules: z
.record(z.string(), // artifact ID
z.array(z.string()) // list of rules
)
.optional()
.describe('Per-artifact rules, keyed by artifact ID'),
});
const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit
/**
* Read and parse openspec/config.yaml from project root.
* Uses resilient parsing - validates each field independently using Zod safeParse.
* Returns null if file doesn't exist.
* Returns partial config if some fields are invalid (with warnings).
*
* Performance note (Jan 2025):
* Benchmarks showed direct file reads are fast enough without caching:
* - Typical config (1KB): ~0.5ms per read
* - Large config (50KB): ~1.6ms per read
* - Missing config: ~0.01ms per read
* Config is read 1-2 times per command (schema resolution + instruction loading),
* adding ~1-3ms total overhead. Caching would add complexity (mtime checks,
* invalidation logic) for negligible benefit. Direct reads also ensure config
* changes are reflected immediately without stale cache issues.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @returns Parsed config or null if file doesn't exist
*/
export function readProjectConfig(projectRoot) {
// Try both .yaml and .yml, prefer .yaml
let configPath = path.join(projectRoot, 'openspec', 'config.yaml');
if (!existsSync(configPath)) {
configPath = path.join(projectRoot, 'openspec', 'config.yml');
if (!existsSync(configPath)) {
return null; // No config is OK
}
}
try {
const content = readFileSync(configPath, 'utf-8');
const raw = parseYaml(content);
if (!raw || typeof raw !== 'object') {
console.warn(`openspec/config.yaml is not a valid YAML object`);
return null;
}
const config = {};
// Parse schema field using Zod
const schemaField = z.string().min(1);
const schemaResult = schemaField.safeParse(raw.schema);
if (schemaResult.success) {
config.schema = schemaResult.data;
}
else if (raw.schema !== undefined) {
console.warn(`Invalid 'schema' field in config (must be non-empty string)`);
}
// Parse context field with size limit
if (raw.context !== undefined) {
const contextField = z.string();
const contextResult = contextField.safeParse(raw.context);
if (contextResult.success) {
const contextSize = Buffer.byteLength(contextResult.data, 'utf-8');
if (contextSize > MAX_CONTEXT_SIZE) {
console.warn(`Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)`);
console.warn(`Ignoring context field`);
}
else {
config.context = contextResult.data;
}
}
else {
console.warn(`Invalid 'context' field in config (must be string)`);
}
}
// Parse rules field using Zod
if (raw.rules !== undefined) {
const rulesField = z.record(z.string(), z.array(z.string()));
// First check if it's an object structure (guard against null since typeof null === 'object')
if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {
const parsedRules = {};
let hasValidRules = false;
for (const [artifactId, rules] of Object.entries(raw.rules)) {
const rulesArrayResult = z.array(z.string()).safeParse(rules);
if (rulesArrayResult.success) {
// Filter out empty strings
const validRules = rulesArrayResult.data.filter((r) => r.length > 0);
if (validRules.length > 0) {
parsedRules[artifactId] = validRules;
hasValidRules = true;
}
if (validRules.length < rulesArrayResult.data.length) {
console.warn(`Some rules for '${artifactId}' are empty strings, ignoring them`);
}
}
else {
console.warn(`Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`);
}
}
if (hasValidRules) {
config.rules = parsedRules;
}
}
else {
console.warn(`Invalid 'rules' field in config (must be object)`);
}
}
// Return partial config even if some fields failed
return Object.keys(config).length > 0 ? config : null;
}
catch (error) {
console.warn(`Failed to parse openspec/config.yaml:`, error);
return null;
}
}
/**
* Validate artifact IDs in rules against a schema's artifacts.
* Called during instruction loading (when schema is known).
* Returns warnings for unknown artifact IDs.
*
* @param rules - The rules object from config
* @param validArtifactIds - Set of valid artifact IDs from the schema
* @param schemaName - Name of the schema for error messages
* @returns Array of warning messages for unknown artifact IDs
*/
export function validateConfigRules(rules, validArtifactIds, schemaName) {
const warnings = [];
for (const artifactId of Object.keys(rules)) {
if (!validArtifactIds.has(artifactId)) {
const validIds = Array.from(validArtifactIds).sort().join(', ');
warnings.push(`Unknown artifact ID in rules: "${artifactId}". ` +
`Valid IDs for schema "${schemaName}": ${validIds}`);
}
}
return warnings;
}
/**
* Suggest valid schema names when user provides invalid schema.
* Uses fuzzy matching to find similar names.
*
* @param invalidSchemaName - The invalid schema name from config
* @param availableSchemas - List of available schemas with their type (built-in or project-local)
* @returns Error message with suggestions and available schemas
*/
export function suggestSchemas(invalidSchemaName, availableSchemas) {
// Simple fuzzy match: Levenshtein distance
function levenshtein(a, b) {
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
}
else {
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
}
return matrix[b.length][a.length];
}
// Find closest matches (distance <= 3)
const suggestions = availableSchemas
.map((s) => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) }))
.filter((s) => s.distance <= 3)
.sort((a, b) => a.distance - b.distance)
.slice(0, 3);
const builtIn = availableSchemas.filter((s) => s.isBuiltIn).map((s) => s.name);
const projectLocal = availableSchemas.filter((s) => !s.isBuiltIn).map((s) => s.name);
let message = `Schema '${invalidSchemaName}' not found in openspec/config.yaml\n\n`;
if (suggestions.length > 0) {
message += `Did you mean one of these?\n`;
suggestions.forEach((s) => {
const type = s.isBuiltIn ? 'built-in' : 'project-local';
message += ` - ${s.name} (${type})\n`;
});
message += '\n';
}
message += `Available schemas:\n`;
if (builtIn.length > 0) {
message += ` Built-in: ${builtIn.join(', ')}\n`;
}
if (projectLocal.length > 0) {
message += ` Project-local: ${projectLocal.join(', ')}\n`;
}
else {
message += ` Project-local: (none found)\n`;
}
message += `\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;
return message;
}
//# sourceMappingURL=project-config.js.map