diff --git a/CHANGELOG.md b/CHANGELOG.md index 3753dd0d8..bab7feadf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # @fission-ai/openspec +## Unreleased + +### Patch Changes + +- Project-scoped `profile: custom` now requires project-scoped `workflows` for profile-driven commands. OpenSpec will fail with an actionable error instead of silently inheriting a developer's global workflows, while raw `openspec config --scope project set workflows ...` remains available to repair the project config. + +- Project config filename resolution is now consistent across runtime reads and `openspec config --scope project` writes. OpenSpec preserves an existing `openspec/config.yml`, defaults new writes to `openspec/config.yaml`, and fails explicitly when both files exist. + ## 1.3.1 ### Patch Changes diff --git a/docs/cli.md b/docs/cli.md index 81753560a..65b9d23bd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -137,7 +137,12 @@ openspec/ ### `openspec update` -Update OpenSpec instruction files after upgrading the CLI. Re-generates AI tool configuration files using your current global profile, selected workflows, and delivery mode. +Update OpenSpec instruction files after upgrading the CLI. Re-generates AI tool configuration files using effective profile settings resolved by precedence: + +- CLI scope override (`--scope`) when provided +- Project config (`openspec/config.yaml` or `openspec/config.yml`) +- Global config +- Built-in defaults (`profile: core`, `delivery: both`) ``` openspec update [path] [options] @@ -154,6 +159,7 @@ openspec update [path] [options] | Option | Description | |--------|-------------| | `--force` | Force update even when files are up to date | +| `--scope ` | Resolution scope override: `global` or `project` | **Example:** @@ -161,8 +167,18 @@ openspec update [path] [options] # Update instruction files after npm upgrade npm update @fission-ai/openspec openspec update + +# Force global-only profile resolution for this run +openspec update --scope global + +# Force project-prioritized resolution for this run +openspec update --scope project ``` +If project config sets `profile: custom`, it must also define project `workflows`. Plain `openspec update` and `openspec update --scope project` will fail rather than inherit a developer's global workflows. Use `openspec update --scope global` to ignore project overrides for a run. + +If both `openspec/config.yaml` and `openspec/config.yml` exist, `openspec update` fails with an explicit error instead of picking one implicitly. Remove one file to continue. + --- ## Workspace Commands @@ -889,7 +905,7 @@ spec-driven resolves from: package **Schema precedence:** 1. Project: `openspec/schemas//` -2. User: `~/.local/share/openspec/schemas//` +2. Global: `~/.local/share/openspec/schemas//` 3. Package: Built-in schemas --- @@ -898,7 +914,14 @@ spec-driven resolves from: package ### `openspec config` -View and modify global OpenSpec configuration. +View and modify OpenSpec configuration. + +Default scope is `global`. Use `--scope project` to read/write project-scoped profile settings (`profile`, `delivery`, `workflows`) in `openspec/config.yaml` (or an existing `config.yml`). +If you already use global-only config, no migration is required: existing commands keep global behavior unless you explicitly opt into `--scope project`. + +Project-scoped writes preserve the existing filename when the repo already uses `openspec/config.yml`, and default to creating `openspec/config.yaml` when neither file exists. If both files exist, project-scoped config commands fail until one file is removed. + +If project config sets `profile: custom`, it must also define project `workflows`. Project-scoped commands that show effective settings, such as `list` and the interactive `profile` wizard, will fail until the workflows are added. Raw write commands such as `openspec config --scope project set workflows ...` still work so you can repair the config. ``` openspec config [options] @@ -933,10 +956,10 @@ openspec config get telemetry.enabled openspec config set telemetry.enabled false # Set a string value explicitly -openspec config set user.name "My Name" --string +openspec config set custom.name "My Name" --string # Remove a custom setting -openspec config unset user.name +openspec config unset custom.name # Reset all configuration openspec config reset --all --yes @@ -949,6 +972,18 @@ openspec config profile # Fast preset: switch workflows to core (keeps delivery mode) openspec config profile core + +# Set project-scoped profile override +openspec config --scope project set profile custom + +# Repair a project-scoped custom profile by setting workflows explicitly +openspec config --scope project set workflows '["explore", "verify"]' + +# Read project-scoped profile key +openspec config --scope project get profile + +# Run profile wizard against project scope +openspec config --scope project profile ``` `openspec config profile` starts with a current-state summary, then lets you choose: @@ -958,9 +993,9 @@ openspec config profile core - Keep current settings (exit) If you keep current settings, no changes are written and no update prompt is shown. -If there are no config changes but the current project files are out of sync with your global profile/delivery, OpenSpec will show a warning and suggest running `openspec update`. +If there are no config changes but the current project files are out of sync with your active scope settings, OpenSpec will show a warning and suggest running `openspec update`. Pressing `Ctrl+C` also cancels the flow cleanly (no stack trace) and exits with code `130`. -In the workflow checklist, `[x]` means the workflow is selected in global config. To apply those selections to project files, run `openspec update` (or choose `Apply changes to this project now?` when prompted inside a project). +In the workflow checklist, `[x]` means the workflow is selected in the effective scope state for this command. To apply those selections to project files, run `openspec update` (or choose `Apply changes to this project now?` when prompted inside a project). **Interactive examples:** diff --git a/docs/customization.md b/docs/customization.md index 3c20a1d65..9104dcccb 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -12,11 +12,12 @@ OpenSpec provides three levels of customization: ## Project Configuration -The `openspec/config.yaml` file is the easiest way to customize OpenSpec for your team. It lets you: +The `openspec/config.yaml` file, or an existing `openspec/config.yml`, is the easiest way to customize OpenSpec for your team. It lets you: - **Set a default schema** - Skip `--schema` on every command - **Inject project context** - AI sees your tech stack, conventions, etc. - **Add per-artifact rules** - Custom rules for specific artifacts +- **Override profile settings per project** - Set `profile`, `delivery`, and `workflows` locally ### Quick Setup @@ -30,6 +31,16 @@ This walks you through creating a config interactively. Or create one manually: # openspec/config.yaml schema: spec-driven +# Optional: project-scoped profile settings +profile: custom +delivery: both +workflows: + - propose + - explore + - apply + - verify + - archive + context: | Tech stack: TypeScript, React, Node.js, PostgreSQL API style: RESTful, documented in docs/api.md @@ -45,6 +56,12 @@ rules: - Reference existing patterns before inventing new ones ``` +`workflows` is applied only when `profile: custom`. If `profile: core`, OpenSpec always uses the core workflow set. + +If project config sets `profile: custom`, it must also set project `workflows`. OpenSpec will not silently inherit workflows from a developer's global config for that case, because that would make shared repo behavior non-deterministic. + +OpenSpec preserves `openspec/config.yml` when that is the file already present in a repo, and defaults new writes to `openspec/config.yaml` when neither filename exists. If both files exist at once, project-scoped config commands and `openspec update` fail until you remove one. + ### How It Works **Default schema:** @@ -80,13 +97,28 @@ Tech stack: TypeScript, React, Node.js, PostgreSQL - **Context** appears in ALL artifacts - **Rules** ONLY appear for the matching artifact +### Profile Resolution Order + +For profile-driven behavior (for example `openspec update`), OpenSpec resolves settings in this order: + +1. CLI scope override (if `--scope` is provided) +2. Project config (`openspec/config.yaml` or existing `openspec/config.yml`) +3. Global config (`openspec config ...`) +4. Defaults (`profile: core`, `delivery: both`, profile-derived workflows) + +This is key-by-key fallback, so partial project settings are valid. Example: if project config only sets `delivery`, the active profile can still come from global config. + +One exception is `profile: custom` in project config: project `workflows` must also be present. If they are missing, profile-driven commands such as `openspec update`, `openspec config --scope project list`, and `openspec config --scope project profile` fail with an actionable error instead of inheriting global workflows. + +If you need to ignore project overrides for a run, use `openspec update --scope global`. If you need to repair the project config, write the missing workflows with `openspec config --scope project set workflows '["explore"]'` (replace the array with your project's workflow list). + ### Schema Resolution Order When OpenSpec needs a schema, it checks in this order: 1. CLI flag: `--schema ` 2. Change metadata (`.openspec.yaml` in the change folder) -3. Project config (`openspec/config.yaml`) +3. Project config (`openspec/config.yaml` or existing `openspec/config.yml`) 4. Default (`spec-driven`) --- @@ -259,7 +291,7 @@ openspec schema which my-workflow openspec schema which --all ``` -Output shows whether it's from your project, user directory, or the package: +Output shows whether it's from your project, global directory, or the package: ```text Schema: my-workflow @@ -269,7 +301,7 @@ Path: /path/to/project/openspec/schemas/my-workflow --- -> **Note:** OpenSpec also supports user-level schemas at `~/.local/share/openspec/schemas/` for sharing across projects, but project-level schemas in `openspec/schemas/` are recommended since they're version-controlled with your code. +> **Note:** OpenSpec also supports global schemas at `~/.local/share/openspec/schemas/` for sharing across projects, but project-level schemas in `openspec/schemas/` are recommended since they're version-controlled with your code. --- diff --git a/src/cli/index.ts b/src/cli/index.ts index f1278dbd7..72e65877a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -158,10 +158,18 @@ program .command('update [path]') .description('Update OpenSpec instruction files') .option('--force', 'Force update even when tools are up to date') - .action(async (targetPath = '.', options?: { force?: boolean }) => { + .option('--scope ', 'Profile resolution scope override (global or project)') + .action(async (targetPath = '.', options?: { force?: boolean; scope?: string }) => { try { + if (options?.scope && options.scope !== 'global' && options.scope !== 'project') { + throw new Error(`Invalid scope "${options.scope}". Use "global" or "project".`); + } + const resolvedPath = path.resolve(targetPath); - const updateCommand = new UpdateCommand({ force: options?.force }); + const updateCommand = new UpdateCommand({ + force: options?.force, + scope: options?.scope as 'global' | 'project' | undefined, + }); await updateCommand.execute(resolvedPath); } catch (error) { console.log(); // Empty line for spacing @@ -426,7 +434,7 @@ program .command('status') .description('Display artifact completion status for a change') .option('--change ', 'Change name to show status for') - .option('--schema ', 'Schema override (auto-detected from config.yaml)') + .option('--schema ', 'Schema override (auto-detected from project config)') .option('--json', 'Output as JSON') .action(async (options: StatusOptions) => { try { @@ -443,7 +451,7 @@ program .command('instructions [artifact]') .description('Output enriched instructions for creating an artifact or applying tasks') .option('--change ', 'Change name') - .option('--schema ', 'Schema override (auto-detected from config.yaml)') + .option('--schema ', 'Schema override (auto-detected from project config)') .option('--json', 'Output as JSON') .action(async (artifactId: string | undefined, options: InstructionsOptions) => { try { diff --git a/src/commands/config.ts b/src/commands/config.ts index 42c736d14..26ba4c2f4 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { spawn, execSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { parseDocument, isMap } from 'yaml'; import { getGlobalConfigPath, getGlobalConfig, @@ -22,6 +23,12 @@ import { import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js'; import { OPENSPEC_DIR_NAME } from '../core/config.js'; import { hasProjectConfigDrift } from '../core/profile-sync-drift.js'; +import { readProjectConfig, resolveProjectConfigPath } from '../core/project-config.js'; +import { + resolveEffectiveProfileSettings, + type ConfigScope, + type ProfileValueSource, +} from '../core/profile-resolution.js'; type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; @@ -41,6 +48,13 @@ interface WorkflowPromptMeta { description: string; } +interface ProjectConfigFile { + path: string; + exists: boolean; + content: Record; + document: ReturnType; +} + const WORKFLOW_PROMPT_META: Record = { propose: { name: 'Propose change', @@ -88,6 +102,199 @@ const WORKFLOW_PROMPT_META: Record = { }, }; +const DEFAULT_PROJECT_SCHEMA = 'spec-driven'; +const PROJECT_PROFILE_KEYS = new Set(['profile', 'delivery', 'workflows']); + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readRawProjectConfigFile(projectDir: string): ProjectConfigFile { + const resolved = resolveProjectConfigPath(projectDir); + + if (!resolved.exists) { + return { + path: resolved.path, + exists: false, + content: {}, + document: parseDocument(''), + }; + } + + const fileContent = fs.readFileSync(resolved.path, 'utf-8'); + const document = parseDocument(fileContent); + if (document.errors.length > 0) { + throw new Error( + `Invalid YAML in ${path.relative(projectDir, resolved.path)}: ${document.errors[0]?.message}` + ); + } + + const parsed = document.toJS(); + + if (parsed == null) { + return { + path: resolved.path, + exists: true, + content: {}, + document, + }; + } + + if (!isObjectRecord(parsed)) { + throw new Error(`Invalid YAML object in ${path.relative(projectDir, resolved.path)}`); + } + + return { + path: resolved.path, + exists: true, + content: { ...parsed }, + document, + }; +} + +function writeProjectConfigFile(file: ProjectConfigFile): void { + fs.mkdirSync(path.dirname(file.path), { recursive: true }); + const serialized = String(file.document); + const contentWithNewline = serialized.endsWith('\n') ? serialized : `${serialized}\n`; + fs.writeFileSync(file.path, contentWithNewline, 'utf-8'); +} + +function ensureProjectConfigForWrite(projectDir: string): ProjectConfigFile { + const file = readRawProjectConfigFile(projectDir); + if (!file.exists && file.document.get('schema') === undefined) { + file.document.set('schema', DEFAULT_PROJECT_SCHEMA); + } + return file; +} + +function toPathSegments(key: string): string[] { + return key.split('.'); +} + +function setProjectConfigDocumentValue(document: ReturnType, key: string, value: unknown): void { + const pathSegments = toPathSegments(key); + + for (let depth = 1; depth < pathSegments.length; depth += 1) { + const parentPath = pathSegments.slice(0, depth); + const existingParent = document.getIn(parentPath, true); + if (existingParent === undefined) { + document.setIn(parentPath, document.createNode({})); + continue; + } + if (!isMap(existingParent)) { + document.setIn(parentPath, document.createNode({})); + } + } + + if (pathSegments.length === 1) { + document.set(pathSegments[0], value); + return; + } + document.setIn(pathSegments, value); +} + +function deleteProjectConfigDocumentValue(document: ReturnType, key: string): boolean { + const pathSegments = toPathSegments(key); + if (!document.hasIn(pathSegments)) { + return false; + } + document.deleteIn(pathSegments); + return true; +} + +function parseScope(rawScope: unknown): ConfigScope | null { + if (rawScope === undefined || rawScope === null || rawScope === 'global') { + return 'global'; + } + if (rawScope === 'project') { + return 'project'; + } + console.error(`Error: Invalid scope "${String(rawScope)}". Use "global" or "project".`); + process.exitCode = 1; + return null; +} + +function isSupportedProjectProfileKey(key: string): boolean { + return !key.includes('.') && PROJECT_PROFILE_KEYS.has(key); +} + +function validateProjectProfileValue(key: string, value: unknown): { valid: boolean; error?: string } { + if (key === 'profile') { + if (value === 'core' || value === 'custom') { + return { valid: true }; + } + return { valid: false, error: 'profile must be "core" or "custom"' }; + } + + if (key === 'delivery') { + if (value === 'both' || value === 'skills' || value === 'commands') { + return { valid: true }; + } + return { valid: false, error: 'delivery must be "both", "skills", or "commands"' }; + } + + if (key === 'workflows') { + if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { + return { valid: true }; + } + return { valid: false, error: 'workflows must be an array of strings' }; + } + + return { valid: false, error: `Unsupported project config key "${key}"` }; +} + +function coerceProjectScopedValue(key: string, value: string, forceString: boolean): unknown { + if (key === 'workflows' && !forceString) { + const trimmed = value.trim(); + if (trimmed === '') { + return []; + } + + if (trimmed.startsWith('[')) { + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (error) { + throw new Error( + `Invalid JSON array for workflows: ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (!Array.isArray(parsed)) { + throw new Error('workflows JSON value must be an array'); + } + + return parsed; + } + + return trimmed + .split(',') + .map((workflow) => workflow.trim()) + .filter((workflow) => workflow.length > 0); + } + + return coerceValue(value, forceString); +} + +function formatSource(source: ProfileValueSource): string { + if (source === 'project') return 'project'; + if (source === 'global') return 'global'; + if (source === 'cli') return 'CLI override'; + return 'default'; +} + +interface ProfileSourceState { + profile: ProfileValueSource; + delivery: ProfileValueSource; + workflows: ProfileValueSource; +} + +interface DriftWarningContext { + scope?: ConfigScope; + effectiveSources?: ProfileSourceState; + hasProjectConfig?: boolean; +} + function isPromptCancellationError(error: unknown): boolean { return ( error instanceof Error && @@ -148,7 +355,7 @@ function stableWorkflowOrder(workflows: readonly string[]): string[] { } /** - * Build a user-facing diff summary between two profile states. + * Build a CLI-facing diff summary between two profile states. */ export function diffProfileState(before: ProfileState, after: ProfileState): ProfileStateDiff { const lines: string[] = []; @@ -189,8 +396,10 @@ export function diffProfileState(before: ProfileState, after: ProfileState): Pro function maybeWarnConfigDrift( projectDir: string, state: ProfileState, - colorize: (message: string) => string + colorize: (message: string) => string, + context: DriftWarningContext = {} ): void { + const scope = context.scope ?? 'global'; const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME); if (!fs.existsSync(openspecDir)) { return; @@ -198,7 +407,22 @@ function maybeWarnConfigDrift( if (!hasProjectConfigDrift(projectDir, state.workflows, state.delivery)) { return; } - console.log(colorize('Warning: Global config is not applied to this project. Run `openspec update` to sync.')); + + const hasProjectSource = + context.effectiveSources !== undefined && + [ + context.effectiveSources.profile, + context.effectiveSources.delivery, + context.effectiveSources.workflows, + ].some((source) => source === 'project'); + const useProjectWording = + scope === 'project' && (hasProjectSource || context.hasProjectConfig === true); + + const message = + useProjectWording + ? 'Warning: Project config is not applied to this project. Run `openspec update` to sync.' + : 'Warning: Global config is not applied to this project. Run `openspec update` to sync.'; + console.log(colorize(message)); } /** @@ -209,22 +433,30 @@ function maybeWarnConfigDrift( export function registerConfigCommand(program: Command): void { const configCmd = program .command('config') - .description('View and modify global OpenSpec configuration') - .option('--scope ', 'Config scope (only "global" supported currently)') - .hook('preAction', (thisCommand) => { - const opts = thisCommand.opts(); - if (opts.scope && opts.scope !== 'global') { - console.error('Error: Project-local config is not yet implemented'); - process.exit(1); - } - }); + .description('View and modify OpenSpec configuration') + .option('--scope ', 'Config scope ("global" or "project")', 'global'); // config path configCmd .command('path') .description('Show config file location') .action(() => { - console.log(getGlobalConfigPath()); + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'global') { + console.log(getGlobalConfigPath()); + return; + } + + try { + console.log(resolveProjectConfigPath(process.cwd()).path); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } }); // config list @@ -233,11 +465,19 @@ export function registerConfigCommand(program: Command): void { .description('Show all current settings') .option('--json', 'Output as JSON') .action((options: { json?: boolean }) => { - const config = getGlobalConfig(); + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'global') { + const config = getGlobalConfig(); + + if (options.json) { + console.log(JSON.stringify(config, null, 2)); + return; + } - if (options.json) { - console.log(JSON.stringify(config, null, 2)); - } else { // Read raw config to determine which values are explicit vs defaults const configPath = getGlobalConfigPath(); let rawConfig: Record = {}; @@ -264,6 +504,62 @@ export function registerConfigCommand(program: Command): void { } else { console.log(` workflows: (none)`); } + + return; + } + + const projectDir = process.cwd(); + + let projectFile: ProjectConfigFile; + let projectConfig: NonNullable>; + let effective: ReturnType; + try { + projectFile = readRawProjectConfigFile(projectDir); + projectConfig = readProjectConfig(projectDir) ?? {}; + effective = resolveEffectiveProfileSettings({ + projectConfig, + globalConfig: getGlobalConfig(), + }); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + if (options.json) { + console.log( + JSON.stringify( + { + raw: projectFile.content, + effective: { + profile: effective.profile, + delivery: effective.delivery, + workflows: effective.workflows, + sources: effective.sources, + }, + }, + null, + 2 + ) + ); + return; + } + + console.log( + Object.keys(projectFile.content).length > 0 + ? formatValueYaml(projectFile.content) + : '{}' + ); + + console.log(`\nProfile settings (effective):`); + console.log(` profile: ${effective.profile} (${formatSource(effective.sources.profile)})`); + console.log(` delivery: ${effective.delivery} (${formatSource(effective.sources.delivery)})`); + if (effective.profile === 'core') { + console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`); + } else if (effective.workflows.length > 0) { + console.log(` workflows: ${effective.workflows.join(', ')} (${formatSource(effective.sources.workflows)})`); + } else { + console.log(` workflows: (none)`); } }); @@ -271,9 +567,38 @@ export function registerConfigCommand(program: Command): void { configCmd .command('get ') .description('Get a specific value (raw, scriptable)') - .action((key: string) => { - const config = getGlobalConfig(); - const value = getNestedValue(config as Record, key); + .option('--allow-unknown', 'Allow getting unknown keys') + .action((key: string, options: { allowUnknown?: boolean }) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + const allowUnknown = Boolean(options.allowUnknown); + + if (scope === 'project' && !allowUnknown && !isSupportedProjectProfileKey(key)) { + console.error( + `Error: Project scope only supports profile-related keys: ${Array.from(PROJECT_PROFILE_KEYS).join(', ')}` + ); + console.error('Pass --allow-unknown to bypass this check.'); + process.exitCode = 1; + return; + } + + let config: Record; + if (scope === 'global') { + config = getGlobalConfig() as Record; + } else { + try { + config = readRawProjectConfigFile(process.cwd()).content; + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + } + + const value = getNestedValue(config, key); if (value === undefined) { process.exitCode = 1; @@ -294,38 +619,92 @@ export function registerConfigCommand(program: Command): void { .option('--string', 'Force value to be stored as string') .option('--allow-unknown', 'Allow setting unknown keys') .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + const allowUnknown = Boolean(options.allowUnknown); - const keyValidation = validateConfigKeyPath(key); - if (!keyValidation.valid && !allowUnknown) { - const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : ''; - console.error(`Error: Invalid configuration key "${key}".${reason}`); - console.error('Use "openspec config list" to see available keys.'); + + if (scope === 'global') { + const keyValidation = validateConfigKeyPath(key); + if (!keyValidation.valid && !allowUnknown) { + const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : ''; + console.error(`Error: Invalid configuration key "${key}".${reason}`); + console.error('Use "openspec config list" to see available keys.'); + console.error('Pass --allow-unknown to bypass this check.'); + process.exitCode = 1; + return; + } + + const config = getGlobalConfig() as Record; + const coercedValue = coerceValue(value, options.string || false); + + // Create a copy to validate before saving + const newConfig = JSON.parse(JSON.stringify(config)); + setNestedValue(newConfig, key, coercedValue); + + // Validate the new config + const validation = validateConfig(newConfig); + if (!validation.success) { + console.error(`Error: Invalid configuration - ${validation.error}`); + process.exitCode = 1; + return; + } + + // Apply changes and save + setNestedValue(config, key, coercedValue); + saveGlobalConfig(config as GlobalConfig); + + const displayValue = + typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue); + console.log(`Set ${key} = ${displayValue}`); + return; + } + + if (!allowUnknown && !isSupportedProjectProfileKey(key)) { + console.error( + `Error: Project scope only supports profile-related keys: ${Array.from(PROJECT_PROFILE_KEYS).join(', ')}` + ); console.error('Pass --allow-unknown to bypass this check.'); process.exitCode = 1; return; } - const config = getGlobalConfig() as Record; - const coercedValue = coerceValue(value, options.string || false); + let coercedValue: unknown; + try { + coercedValue = coerceProjectScopedValue(key, value, options.string || false); + } catch (error) { + console.error( + `Error: Invalid configuration - ${error instanceof Error ? error.message : String(error)}` + ); + process.exitCode = 1; + return; + } - // Create a copy to validate before saving - const newConfig = JSON.parse(JSON.stringify(config)); - setNestedValue(newConfig, key, coercedValue); + if (isSupportedProjectProfileKey(key)) { + const validation = validateProjectProfileValue(key, coercedValue); + if (!validation.valid) { + console.error(`Error: Invalid configuration - ${validation.error}`); + process.exitCode = 1; + return; + } + } - // Validate the new config - const validation = validateConfig(newConfig); - if (!validation.success) { - console.error(`Error: Invalid configuration - ${validation.error}`); + let projectFile: ProjectConfigFile; + try { + projectFile = ensureProjectConfigForWrite(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; return; } - // Apply changes and save - setNestedValue(config, key, coercedValue); - saveGlobalConfig(config as GlobalConfig); + setProjectConfigDocumentValue(projectFile.document, key, coercedValue); + writeProjectConfigFile(projectFile); const displayValue = - typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue); + typeof coercedValue === 'string' ? `"${coercedValue}"` : JSON.stringify(coercedValue); console.log(`Set ${key} = ${displayValue}`); }); @@ -333,13 +712,51 @@ export function registerConfigCommand(program: Command): void { configCmd .command('unset ') .description('Remove a key (revert to default)') - .action((key: string) => { - const config = getGlobalConfig() as Record; - const existed = deleteNestedValue(config, key); + .option('--allow-unknown', 'Allow unsetting unknown keys') + .action((key: string, options: { allowUnknown?: boolean }) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + const allowUnknown = Boolean(options.allowUnknown); + + if (scope === 'global') { + const config = getGlobalConfig() as Record; + const existed = deleteNestedValue(config, key); + + if (existed) { + saveGlobalConfig(config as GlobalConfig); + console.log(`Unset ${key} (reverted to default)`); + } else { + console.log(`Key "${key}" was not set`); + } + return; + } + + if (!allowUnknown && !isSupportedProjectProfileKey(key)) { + console.error( + `Error: Project scope only supports profile-related keys: ${Array.from(PROJECT_PROFILE_KEYS).join(', ')}` + ); + console.error('Pass --allow-unknown to bypass this check.'); + process.exitCode = 1; + return; + } + + let projectFile: ProjectConfigFile; + try { + projectFile = readRawProjectConfigFile(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + const existed = deleteProjectConfigDocumentValue(projectFile.document, key); if (existed) { - saveGlobalConfig(config as GlobalConfig); - console.log(`Unset ${key} (reverted to default)`); + writeProjectConfigFile(projectFile); + console.log(`Unset ${key} (reverted to fallback)`); } else { console.log(`Key "${key}" was not set`); } @@ -352,6 +769,17 @@ export function registerConfigCommand(program: Command): void { .option('--all', 'Reset all configuration (required)') .option('-y, --yes', 'Skip confirmation prompts') .action(async (options: { all?: boolean; yes?: boolean }) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'project') { + console.error('Error: config reset is only supported for global scope'); + process.exitCode = 1; + return; + } + if (!options.all) { console.error('Error: --all flag is required for reset'); console.error('Usage: openspec config reset --all [-y]'); @@ -391,6 +819,17 @@ export function registerConfigCommand(program: Command): void { .command('edit') .description('Open config in $EDITOR') .action(async () => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'project') { + console.error('Error: config edit is only supported for global scope'); + process.exitCode = 1; + return; + } + const editor = process.env.EDITOR || process.env.VISUAL; if (!editor) { @@ -454,13 +893,33 @@ export function registerConfigCommand(program: Command): void { .command('profile [preset]') .description('Configure workflow profile (interactive picker or preset shortcut)') .action(async (preset?: string) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + // Preset shortcut: `openspec config profile core` if (preset === 'core') { - const config = getGlobalConfig(); - config.profile = 'core'; - config.workflows = [...CORE_WORKFLOWS]; - // Preserve delivery setting - saveGlobalConfig(config); + if (scope === 'global') { + const config = getGlobalConfig(); + config.profile = 'core'; + config.workflows = [...CORE_WORKFLOWS]; + // Preserve delivery setting + saveGlobalConfig(config); + } else { + let projectFile: ProjectConfigFile; + try { + projectFile = ensureProjectConfigForWrite(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + setProjectConfigDocumentValue(projectFile.document, 'profile', 'core'); + setProjectConfigDocumentValue(projectFile.document, 'workflows', [...CORE_WORKFLOWS]); + writeProjectConfigFile(projectFile); + } console.log('Config updated. Run `openspec update` in your projects to apply.'); return; } @@ -483,8 +942,26 @@ export function registerConfigCommand(program: Command): void { const chalk = (await import('chalk')).default; try { - const config = getGlobalConfig(); - const currentState = resolveCurrentProfileState(config); + const globalConfig = getGlobalConfig(); + const projectDir = process.cwd(); + let hasProjectConfig = false; + let projectConfig: NonNullable> | null = null; + + if (scope === 'project') { + const resolvedProjectConfigPath = resolveProjectConfigPath(projectDir); + hasProjectConfig = resolvedProjectConfigPath.exists; + projectConfig = readProjectConfig(projectDir) ?? {}; + } + + const effective = resolveEffectiveProfileSettings({ + projectConfig, + globalConfig, + }); + const currentState: ProfileState = { + profile: effective.profile, + delivery: effective.delivery, + workflows: [...effective.workflows], + }; console.log(chalk.bold('\nCurrent profile settings')); console.log(` Delivery: ${currentState.delivery}`); @@ -521,7 +998,11 @@ export function registerConfigCommand(program: Command): void { if (action === 'keep') { console.log('No config changes.'); - maybeWarnConfigDrift(process.cwd(), currentState, chalk.yellow); + maybeWarnConfigDrift(projectDir, currentState, chalk.yellow, { + scope, + effectiveSources: effective.sources, + hasProjectConfig, + }); return; } @@ -596,7 +1077,11 @@ export function registerConfigCommand(program: Command): void { const diff = diffProfileState(currentState, nextState); if (!diff.hasChanges) { console.log('No config changes.'); - maybeWarnConfigDrift(process.cwd(), nextState, chalk.yellow); + maybeWarnConfigDrift(projectDir, nextState, chalk.yellow, { + scope, + effectiveSources: effective.sources, + hasProjectConfig, + }); return; } @@ -606,13 +1091,33 @@ export function registerConfigCommand(program: Command): void { } console.log(); - config.profile = nextState.profile; - config.delivery = nextState.delivery; - config.workflows = nextState.workflows; - saveGlobalConfig(config); + if (scope === 'global') { + const config = getGlobalConfig(); + config.profile = nextState.profile; + config.delivery = nextState.delivery; + config.workflows = nextState.workflows; + saveGlobalConfig(config); + } else { + let projectFile: ProjectConfigFile; + try { + projectFile = ensureProjectConfigForWrite(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + if (action === 'both' || action === 'workflows') { + setProjectConfigDocumentValue(projectFile.document, 'profile', nextState.profile); + setProjectConfigDocumentValue(projectFile.document, 'workflows', nextState.workflows); + } + if (action === 'both' || action === 'delivery') { + setProjectConfigDocumentValue(projectFile.document, 'delivery', nextState.delivery); + } + writeProjectConfigFile(projectFile); + } // Check if inside an OpenSpec project - const projectDir = process.cwd(); const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME); if (fs.existsSync(openspecDir)) { const applyNow = await confirm({ @@ -622,7 +1127,9 @@ export function registerConfigCommand(program: Command): void { if (applyNow) { try { - execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir }); + const updateCommand = + scope === 'project' ? 'npx openspec update --scope project' : 'npx openspec update --scope global'; + execSync(updateCommand, { stdio: 'inherit', cwd: projectDir }); console.log('Run `openspec update` in your other projects to apply.'); } catch { console.error('`openspec update` failed. Please run it manually to apply the profile changes.'); @@ -639,7 +1146,8 @@ export function registerConfigCommand(program: Command): void { process.exitCode = 130; return; } - throw error; + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; } }); } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 7f8d0b788..264a3ab54 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -12,6 +12,7 @@ import { } from '../core/artifact-graph/resolver.js'; import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js'; import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js'; +import { resolveProjectConfigPath } from '../core/project-config.js'; /** * Schema source location type @@ -868,13 +869,14 @@ export function registerSchemaCommand(program: Command): void { // Update config if --default if (options?.default) { - const configPath = path.join(projectRoot, 'openspec', 'config.yaml'); + const resolvedConfigPath = resolveProjectConfigPath(projectRoot); + const configPath = resolvedConfigPath.path; - if (fs.existsSync(configPath)) { + if (resolvedConfigPath.exists) { const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml'); const configContent = fs.readFileSync(configPath, 'utf-8'); const config = parseYaml(configContent) || {}; - config.defaultSchema = name; + config.schema = name; fs.writeFileSync(configPath, stringifyYaml2(config)); } else { // Create config file @@ -882,7 +884,7 @@ export function registerSchemaCommand(program: Command): void { if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }); } - fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name })); + fs.writeFileSync(configPath, stringifyYaml({ schema: name })); } } diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index fda6a2ddf..8122560b7 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -51,7 +51,18 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Update OpenSpec instruction files', acceptsPositional: true, positionalType: 'path', - flags: [], + flags: [ + { + name: 'force', + description: 'Force update even when tools are up to date', + }, + { + name: 'scope', + description: 'Profile resolution scope override (global or project)', + takesValue: true, + values: ['global', 'project'], + }, + ], }, { name: 'list', @@ -443,13 +454,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'config', - description: 'View and modify global OpenSpec configuration', + description: 'View and modify OpenSpec configuration', flags: [ { name: 'scope', - description: 'Config scope (only "global" supported currently)', + description: 'Config scope ("global" or "project")', takesValue: true, - values: ['global'], + values: ['global', 'project'], }, ], subcommands: [ @@ -469,7 +480,12 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'get', description: 'Get a specific value (raw, scriptable)', acceptsPositional: true, - flags: [], + flags: [ + { + name: 'allow-unknown', + description: 'Allow getting unknown keys', + }, + ], }, { name: 'set', @@ -490,7 +506,12 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'unset', description: 'Remove a key (revert to default)', acceptsPositional: true, - flags: [], + flags: [ + { + name: 'allow-unknown', + description: 'Allow unsetting unknown keys', + }, + ], }, { name: 'reset', diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index abad78df2..f9b88a976 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -20,7 +20,9 @@ export class MarkdownParser { } protected static normalizeContent(content: string): string { - return content.replace(/\r\n?/g, '\n'); + return content + .replace(/\r\n?/g, '\n') + .replace(/^(###)(Requirement:)/gim, '$1 $2'); } protected static buildCodeFenceMask(lines: string[]): boolean[] { diff --git a/src/core/parsers/spec-structure.ts b/src/core/parsers/spec-structure.ts index cfcfe0b1b..70c2bd29d 100644 --- a/src/core/parsers/spec-structure.ts +++ b/src/core/parsers/spec-structure.ts @@ -1,7 +1,7 @@ const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i; const TOP_LEVEL_SECTION_HEADER = /^##\s+/; const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i; -const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/i; +const REQUIREMENT_HEADER = /^###\s*Requirement:\s*(.+)\s*$/i; export interface MainSpecStructureIssue { kind: 'delta-header' | 'requirement-outside-requirements'; diff --git a/src/core/profile-resolution.ts b/src/core/profile-resolution.ts new file mode 100644 index 000000000..e3b085a0a --- /dev/null +++ b/src/core/profile-resolution.ts @@ -0,0 +1,141 @@ +import { + getGlobalConfig, + type Delivery, + type GlobalConfig, + type Profile, +} from './global-config.js'; +import type { ProjectProfileConfig } from './project-config.js'; +import { getProfileWorkflows } from './profiles.js'; + +/** + * Scope override for profile resolution. + * + * - `global`: ignore project config values and resolve from global/default + * - `project`: resolve with project-first fallback behavior + */ +export type ConfigScope = 'global' | 'project'; + +/** + * Source attribution for resolved profile values. + */ +export type ProfileValueSource = 'cli' | 'project' | 'global' | 'default'; + +interface ResolvedValue { + value: T; + source: ProfileValueSource; +} + +function resolveValue(options: { + cliValue?: T; + projectValue?: T; + globalValue?: T; + defaultValue: T; + scopeOverride?: ConfigScope; +}): ResolvedValue { + if (options.cliValue !== undefined) { + return { value: options.cliValue, source: 'cli' }; + } + + if (options.scopeOverride !== 'global' && options.projectValue !== undefined) { + return { value: options.projectValue, source: 'project' }; + } + + if (options.globalValue !== undefined) { + return { value: options.globalValue, source: 'global' }; + } + + return { value: options.defaultValue, source: 'default' }; +} + +/** + * Input options for resolving effective profile settings. + */ +export interface ResolveEffectiveProfileSettingsOptions { + /** Optional scope override for precedence behavior. */ + scopeOverride?: ConfigScope; + /** Optional direct CLI overrides for profile keys. */ + cliOverrides?: ProjectProfileConfig; + /** Optional project config values (usually read from the project config file). */ + projectConfig?: ProjectProfileConfig | null; + /** Optional preloaded global config (defaults to getGlobalConfig()). */ + globalConfig?: GlobalConfig; +} + +/** + * Fully resolved profile settings with source attribution per key. + */ +export interface EffectiveProfileSettings { + profile: Profile; + delivery: Delivery; + workflows: string[]; + sources: { + profile: ProfileValueSource; + delivery: ProfileValueSource; + workflows: ProfileValueSource; + }; +} + +function assertProjectCustomWorkflowsExplicit(options: ResolveEffectiveProfileSettingsOptions): void { + if (options.scopeOverride === 'global') { + return; + } + + if (options.projectConfig?.profile === 'custom' && options.projectConfig.workflows === undefined) { + throw new Error( + 'Project config sets profile: custom but does not define workflows. Add workflows to your project config file, or remove the project profile override.' + ); + } +} + +/** + * Resolve effective profile settings with deterministic precedence: + * CLI override > project config > global config > defaults. + * + * Resolution is key-by-key to support partial project config fallback, + * except that a project-scoped `profile: custom` must define project + * `workflows` explicitly. + */ +export function resolveEffectiveProfileSettings( + options: ResolveEffectiveProfileSettingsOptions = {} +): EffectiveProfileSettings { + assertProjectCustomWorkflowsExplicit(options); + + const globalConfig = options.globalConfig ?? getGlobalConfig(); + const projectConfig = options.projectConfig ?? null; + const cli = options.cliOverrides ?? {}; + + const profile = resolveValue({ + cliValue: cli.profile, + projectValue: projectConfig?.profile, + globalValue: globalConfig.profile, + defaultValue: 'core', + scopeOverride: options.scopeOverride, + }); + + const delivery = resolveValue({ + cliValue: cli.delivery, + projectValue: projectConfig?.delivery, + globalValue: globalConfig.delivery, + defaultValue: 'both', + scopeOverride: options.scopeOverride, + }); + + const configuredWorkflows = resolveValue({ + cliValue: cli.workflows, + projectValue: projectConfig?.workflows, + globalValue: globalConfig.workflows, + defaultValue: undefined, + scopeOverride: options.scopeOverride, + }); + + return { + profile: profile.value, + delivery: delivery.value, + workflows: [...getProfileWorkflows(profile.value, configuredWorkflows.value)], + sources: { + profile: profile.source, + delivery: delivery.source, + workflows: profile.value === 'core' ? profile.source : configuredWorkflows.source, + }, + }; +} diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 6c1ea04a5..f8410b18d 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'fs'; import path from 'path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +import { OPENSPEC_DIR_NAME } from './config.js'; /** * Zod schema for project configuration. @@ -38,14 +39,70 @@ export const ProjectConfigSchema = z.object({ ) .optional() .describe('Per-artifact rules, keyed by artifact ID'), + + // Optional: profile-related settings scoped to this project + profile: z + .enum(['core', 'custom']) + .optional() + .describe('Workflow profile override for this project'), + + delivery: z + .enum(['both', 'skills', 'commands']) + .optional() + .describe('Workflow delivery override for this project'), + + workflows: z + .array(z.string()) + .optional() + .describe('Workflow selection override for this project'), }); +/** + * Runtime-validated project configuration shape. + */ export type ProjectConfig = z.infer; +/** + * Project-scoped profile fields consumed by profile resolution. + */ +export type ProjectProfileConfig = Pick; + const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit +export const AMBIGUOUS_PROJECT_CONFIG_ERROR = + 'Both openspec/config.yaml and openspec/config.yml exist. Remove one to continue.'; + +export interface ResolvedProjectConfigPath { + path: string; + exists: boolean; +} /** - * Read and parse openspec/config.yaml from project root. + * Resolve the active project config path, preserving an existing filename. + * Defaults new writes to openspec/config.yaml when neither file exists. + */ +export function resolveProjectConfigPath(projectRoot: string): ResolvedProjectConfigPath { + const yamlPath = path.join(projectRoot, OPENSPEC_DIR_NAME, 'config.yaml'); + const ymlPath = path.join(projectRoot, OPENSPEC_DIR_NAME, 'config.yml'); + const yamlExists = existsSync(yamlPath); + const ymlExists = existsSync(ymlPath); + + if (yamlExists && ymlExists) { + throw new Error(AMBIGUOUS_PROJECT_CONFIG_ERROR); + } + + if (yamlExists) { + return { path: yamlPath, exists: true }; + } + + if (ymlExists) { + return { path: ymlPath, exists: true }; + } + + return { path: yamlPath, exists: false }; +} + +/** + * Read and parse openspec/config.yaml or openspec/config.yml 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). @@ -63,22 +120,21 @@ const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit * @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: string): ProjectConfig | null { - // 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 - } +export function readProjectConfig(projectRoot: string): Partial | null { + const resolved = resolveProjectConfigPath(projectRoot); + if (!resolved.exists) { + return null; } + const configPath = resolved.path; + const configDisplayPath = `openspec/${path.basename(configPath)}`; + 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`); + console.warn(`${configDisplayPath} is not a valid YAML object`); return null; } @@ -152,10 +208,37 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + if (raw.profile !== undefined) { + const profileResult = z.enum(['core', 'custom']).safeParse(raw.profile); + if (profileResult.success) { + config.profile = profileResult.data; + } else { + console.warn(`Invalid 'profile' field in config (must be one of: core, custom)`); + } + } + + if (raw.delivery !== undefined) { + const deliveryResult = z.enum(['both', 'skills', 'commands']).safeParse(raw.delivery); + if (deliveryResult.success) { + config.delivery = deliveryResult.data; + } else { + console.warn(`Invalid 'delivery' field in config (must be one of: both, skills, commands)`); + } + } + + if (raw.workflows !== undefined) { + const workflowsResult = z.array(z.string()).safeParse(raw.workflows); + if (workflowsResult.success) { + config.workflows = workflowsResult.data; + } else { + console.warn(`Invalid 'workflows' field in config (must be an array of strings)`); + } + } + // Return partial config even if some fields failed - return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; + return Object.keys(config).length > 0 ? config : null; } catch (error) { - console.warn(`Failed to parse openspec/config.yaml:`, error); + console.warn(`Failed to parse ${configDisplayPath}:`, error); return null; } } @@ -237,7 +320,9 @@ export function suggestSchemas( 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`; + let message = + `Schema '${invalidSchemaName}' not found in project config ` + + `(openspec/config.yaml or existing openspec/config.yml)\n\n`; if (suggestions.length > 0) { message += `Did you mean one of these?\n`; @@ -258,7 +343,9 @@ export function suggestSchemas( message += ` Project-local: (none found)\n`; } - message += `\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`; + message += + `\nFix: Edit the project config file and change ` + + `'schema: ${invalidSchemaName}' to a valid schema name`; return message; } diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b..f89e4a41e 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -34,8 +34,8 @@ import { type LegacyDetectionResult, } from './legacy-cleanup.js'; import { isInteractive } from '../utils/interactive.js'; -import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; -import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js'; +import type { Delivery, Profile } from './global-config.js'; +import { ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { WORKFLOW_TO_SKILL_DIR, @@ -43,6 +43,11 @@ import { getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js'; +import { readProjectConfig } from './project-config.js'; +import { + resolveEffectiveProfileSettings, + type ConfigScope, +} from './profile-resolution.js'; import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, @@ -58,6 +63,8 @@ const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const; export interface UpdateCommandOptions { /** Force update even when tools are up to date */ force?: boolean; + /** Optional config-scope override for effective profile resolution */ + scope?: ConfigScope; } /** @@ -73,13 +80,24 @@ export function scanInstalledWorkflows(projectPath: string, toolIds: string[]): return scanInstalledWorkflowsShared(projectPath, tools); } +/** + * Executes profile-aware update and sync operations for configured tools. + */ export class UpdateCommand { private readonly force: boolean; + private readonly scope?: ConfigScope; constructor(options: UpdateCommandOptions = {}) { this.force = options.force ?? false; + this.scope = options.scope; } + /** + * Run the update flow for a project. + * + * @param projectPath - Project root path (absolute or relative) + * @throws Error when OpenSpec is not initialized in the target project + */ async execute(projectPath: string): Promise { const resolvedProjectPath = path.resolve(projectPath); const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME); @@ -94,12 +112,13 @@ export class UpdateCommand { const detectedTools = getAvailableTools(resolvedProjectPath); migrateIfNeededShared(resolvedProjectPath, detectedTools); - // 3. Read global config for profile/delivery - const globalConfig = getGlobalConfig(); - const profile = globalConfig.profile ?? 'core'; - const delivery: Delivery = globalConfig.delivery ?? 'both'; - const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows); - const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => + // 3. Resolve effective profile/delivery/workflows by scope-aware precedence + const effective = resolveEffectiveProfileSettings({ + scopeOverride: this.scope, + projectConfig: readProjectConfig(resolvedProjectPath), + }); + const delivery: Delivery = effective.delivery; + const desiredWorkflows = effective.workflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => (ALL_WORKFLOWS as readonly string[]).includes(workflow) ); const shouldGenerateSkills = delivery !== 'commands'; @@ -156,7 +175,7 @@ export class UpdateCommand { // Still check for new tool directories and extra workflows this.detectNewTools(resolvedProjectPath, configuredTools); this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); - this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows); + this.displayOldCoreCustomProfileNote(effective.profile, effective.workflows); return; } @@ -284,7 +303,7 @@ export class UpdateCommand { // 14. Display note about extra workflows not in profile this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows); - this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows); + this.displayOldCoreCustomProfileNote(effective.profile, effective.workflows); // 15. List affected tools if (updatedTools.length > 0) { diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts index b43749582..6f461b639 100644 --- a/src/utils/change-metadata.ts +++ b/src/utils/change-metadata.ts @@ -152,7 +152,7 @@ export function readChangeMetadata( * Resolution order: * 1. Explicit schema (if provided) * 2. Schema from .openspec.yaml metadata (if exists) - * 3. Schema from openspec/config.yaml (if exists) + * 3. Schema from project config (if present) * 4. Default 'spec-driven' * * @param changeDir - The path to the change directory diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index 22657513d..ab966449d 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -124,6 +124,22 @@ describe('openspec CLI e2e basics', () => { expect(result.stderr).toContain("Unknown item 'does-not-exist'"); }); + it('fails update cleanly when both project config filenames exist', async () => { + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-cli-update-')); + tempRoots.push(base); + const projectDir = path.join(base, 'project'); + await fs.mkdir(path.join(projectDir, 'openspec'), { recursive: true }); + await fs.writeFile(path.join(projectDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + await fs.writeFile(path.join(projectDir, 'openspec', 'config.yml'), 'schema: spec-driven\n'); + + const result = await runCLI(['update'], { cwd: projectDir }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Error: Both openspec/config.yaml and openspec/config.yml exist. Remove one to continue.' + ); + }); + describe('init command non-interactive options', () => { it('initializes with --tools all option', async () => { const projectDir = await prepareFixture('tmp-init'); diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 6208403c2..45d362a48 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -3,6 +3,7 @@ import { Command } from 'commander'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { parse as parseYaml } from 'yaml'; vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), @@ -346,6 +347,62 @@ describe('config profile interactive flow', () => { expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.')); }); + it('project scope keep action should warn with global wording when project profile config is absent', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + setupDriftedProjectArtifacts(tempDir); + select.mockResolvedValueOnce('keep'); + + await runConfigCommand(['--scope', 'project', 'profile']); + + const allLogs = consoleLogSpy.mock.calls.map((args) => args.map(String).join(' ')); + expect(allLogs.some((line) => line.includes('Warning: Global config is not applied to this project.'))).toBe(true); + expect(allLogs.some((line) => line.includes('Warning: Project config is not applied to this project.'))).toBe(false); + }); + + it('project scope keep action should warn with project wording when project config file exists', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + setupDriftedProjectArtifacts(tempDir); + fs.writeFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n', 'utf-8'); + select.mockResolvedValueOnce('keep'); + + await runConfigCommand(['--scope', 'project', 'profile']); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Warning: Project config is not applied to this project.') + ); + }); + + it('project scope profile should fail before prompts when custom project config omits workflows', async () => { + const { select, checkbox, confirm } = await getPromptMocks(); + + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +`, + 'utf-8' + ); + + await runConfigCommand(['--scope', 'project', 'profile']); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Project config sets profile: custom but does not define workflows' + ) + ); + expect(select).not.toHaveBeenCalled(); + expect(checkbox).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + it('changed config should save and ask apply when inside project', async () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); @@ -383,6 +440,97 @@ describe('config profile interactive flow', () => { expect(confirm).not.toHaveBeenCalled(); }); + it('project scope core preset should write project config and preserve project delivery', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills', workflows: ['verify'] }); + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +delivery: commands +` + ); + + await runConfigCommand(['--scope', 'project', 'profile', 'core']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed.profile).toBe('core'); + expect(parsed.delivery).toBe('commands'); + expect(parsed.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(getGlobalConfig()).toMatchObject({ + profile: 'custom', + delivery: 'skills', + workflows: ['verify'], + }); + expect(select).not.toHaveBeenCalled(); + expect(checkbox).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('project scope interactive delivery-only change should persist delivery without forcing profile keys', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('commands'); + confirm.mockResolvedValueOnce(false); + + await runConfigCommand(['--scope', 'project', 'profile']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed).toMatchObject({ + schema: 'spec-driven', + delivery: 'commands', + }); + expect(parsed.profile).toBeUndefined(); + expect(parsed.workflows).toBeUndefined(); + expect(getGlobalConfig()).toMatchObject({ + profile: 'core', + delivery: 'both', + }); + expect(checkbox).not.toHaveBeenCalled(); + }); + + it('project scope interactive workflows-only change should persist profile and workflows', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + select.mockResolvedValueOnce('workflows'); + checkbox.mockResolvedValueOnce(['explore', 'verify']); + confirm.mockResolvedValueOnce(false); + + await runConfigCommand(['--scope', 'project', 'profile']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed).toMatchObject({ + schema: 'spec-driven', + profile: 'custom', + workflows: ['explore', 'verify'], + }); + expect(parsed.delivery).toBeUndefined(); + expect(getGlobalConfig()).toMatchObject({ + profile: 'core', + delivery: 'both', + }); + expect(confirm).toHaveBeenCalledWith({ + message: 'Apply changes to this project now?', + default: true, + }); + }); + it('Ctrl+C should cancel without stack trace and set interrupted exit code', async () => { const { select, checkbox, confirm } = await getPromptMocks(); const cancellationError = new Error('User force closed the prompt with SIGINT'); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 6e65068b8..db8dd2b47 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -2,6 +2,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { Command } from 'commander'; +import { parse as parseYaml } from 'yaml'; + +async function runConfigCommand(args: string[]): Promise { + const { registerConfigCommand } = await import('../../src/commands/config.js'); + const program = new Command(); + registerConfigCommand(program); + await program.parseAsync(['node', 'openspec', 'config', ...args]); +} describe('config command integration', () => { // These tests use real file system operations with XDG_CONFIG_HOME override @@ -91,13 +100,440 @@ describe('config command integration', () => { }); }); +describe('config command project scope', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + let originalExitCode: number | undefined; + let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), `openspec-config-project-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + originalExitCode = process.exitCode; + + process.env.XDG_CONFIG_HOME = tempDir; + process.chdir(tempDir); + process.exitCode = undefined; + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + process.chdir(originalCwd); + process.exitCode = originalExitCode; + fs.rmSync(tempDir, { recursive: true, force: true }); + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + vi.resetModules(); + }); + + it('set/get with --scope project writes project config without mutating global config', async () => { + const { getGlobalConfig } = await import('../../src/core/global-config.js'); + + await runConfigCommand(['--scope', 'project', 'set', 'profile', 'custom']); + + const projectConfigPath = path.join(tempDir, 'openspec', 'config.yaml'); + expect(fs.existsSync(projectConfigPath)).toBe(true); + + const parsed = parseYaml(fs.readFileSync(projectConfigPath, 'utf-8')) as Record; + expect(parsed).toMatchObject({ + schema: 'spec-driven', + profile: 'custom', + }); + + expect(getGlobalConfig().profile).toBe('core'); + + consoleLogSpy.mockClear(); + await runConfigCommand(['--scope', 'project', 'get', 'profile']); + expect(consoleLogSpy).toHaveBeenCalledWith('custom'); + }); + + it('path --scope project preserves an existing config.yml filename', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'openspec', 'config.yml'), 'schema: spec-driven\n', 'utf-8'); + + await runConfigCommand(['--scope', 'project', 'path']); + + expect(consoleLogSpy).toHaveBeenCalledWith( + fs.realpathSync(path.join(tempDir, 'openspec', 'config.yml')) + ); + }); + + it('list --scope project --json includes raw and effective sections', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: ['verify'], + }); + + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + await runConfigCommand(['--scope', 'project', 'list', '--json']); + + const payload = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] as string) as { + raw: Record; + effective: { + profile: string; + delivery: string; + workflows: string[]; + sources: { + profile: string; + delivery: string; + workflows: string; + }; + }; + }; + + expect(payload.raw).toMatchObject({ + schema: 'spec-driven', + profile: 'custom', + workflows: ['explore'], + }); + expect(payload.effective).toEqual({ + profile: 'custom', + delivery: 'commands', + workflows: ['explore'], + sources: { + profile: 'project', + delivery: 'global', + workflows: 'project', + }, + }); + }); + + it('list --scope project fails gracefully when project custom profile omits workflows', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yml'), + `schema: spec-driven +profile: custom +` + ); + + await expect(runConfigCommand(['--scope', 'project', 'list'])).resolves.toBeUndefined(); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Project config sets profile: custom but does not define workflows' + ) + ); + }); + + it('project-scoped writes preserve existing schema/context/rules fields', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Keep me +rules: + proposal: + - Keep this +` + ); + + await runConfigCommand(['--scope', 'project', 'set', 'delivery', 'commands']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed).toMatchObject({ + schema: 'spec-driven', + context: 'Keep me', + delivery: 'commands', + }); + expect(parsed.rules).toEqual({ proposal: ['Keep this'] }); + }); + + it('project-scoped writes update an existing config.yml file instead of creating config.yaml', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yml'), + `schema: spec-driven +delivery: both +` + ); + + await runConfigCommand(['--scope', 'project', 'set', 'profile', 'custom']); + + expect(fs.existsSync(path.join(tempDir, 'openspec', 'config.yaml'))).toBe(false); + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yml'), 'utf-8') + ) as Record; + expect(parsed).toMatchObject({ + schema: 'spec-driven', + delivery: 'both', + profile: 'custom', + }); + }); + + it('project-scoped set preserves YAML comments and key order', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `# Top-level comment +schema: spec-driven # schema comment +# delivery comment +delivery: both +` + ); + + await runConfigCommand(['--scope', 'project', 'set', 'profile', 'custom']); + + const written = fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8'); + + expect(written).toContain('# Top-level comment'); + expect(written).toContain('# schema comment'); + expect(written).toContain('# delivery comment'); + + const schemaIndex = written.indexOf('schema: spec-driven'); + const deliveryIndex = written.indexOf('delivery: both'); + const profileIndex = written.indexOf('profile: custom'); + expect(schemaIndex).toBeGreaterThanOrEqual(0); + expect(deliveryIndex).toBeGreaterThan(schemaIndex); + expect(profileIndex).toBeGreaterThan(deliveryIndex); + }); + + it('unset with --scope project removes key without mutating global config', async () => { + const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'both', workflows: ['explore'] }); + + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +` + ); + + await runConfigCommand(['--scope', 'project', 'unset', 'profile']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + expect(parsed).toEqual({ schema: 'spec-driven' }); + expect(getGlobalConfig().profile).toBe('custom'); + }); + + it('project-scoped unset preserves comments on untouched keys', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `# keep this +schema: spec-driven # and this +profile: custom +delivery: both +` + ); + + await runConfigCommand(['--scope', 'project', 'unset', 'profile']); + + const written = fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8'); + expect(written).toContain('# keep this'); + expect(written).toContain('# and this'); + expect(written).not.toContain('profile: custom'); + expect(written).toContain('delivery: both'); + }); + + it('project-scoped unset updates an existing config.yml file instead of creating config.yaml', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + await runConfigCommand(['--scope', 'project', 'unset', 'profile']); + + expect(fs.existsSync(path.join(tempDir, 'openspec', 'config.yaml'))).toBe(false); + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yml'), 'utf-8') + ) as Record; + expect(parsed).toEqual({ + schema: 'spec-driven', + workflows: ['explore'], + }); + }); + + it('rejects unsupported project-scoped keys', async () => { + await runConfigCommand(['--scope', 'project', 'set', 'schema', 'spec-driven']); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Project scope only supports profile-related keys') + ); + }); + + it('rejects malformed JSON array syntax for project-scoped workflows', async () => { + const projectConfigPath = path.join(tempDir, 'openspec', 'config.yaml'); + + await runConfigCommand(['--scope', 'project', 'set', 'workflows', '[propose, explore]']); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON array for workflows')); + expect(fs.existsSync(projectConfigPath)).toBe(false); + }); + + it('accepts JSON array syntax for project-scoped workflows', async () => { + await runConfigCommand(['--scope', 'project', 'set', 'workflows', '["propose", "explore"]']); + + expect(process.exitCode).toBeUndefined(); + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + expect(parsed.workflows).toEqual(['propose', 'explore']); + }); + + it('project-scoped set workflows remains usable to repair custom project config', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +` + ); + + await runConfigCommand(['--scope', 'project', 'set', 'workflows', '["explore"]']); + + expect(process.exitCode).toBeUndefined(); + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + expect(parsed).toMatchObject({ + schema: 'spec-driven', + profile: 'custom', + workflows: ['explore'], + }); + }); + + it('project scope get honors --allow-unknown for unknown keys', async () => { + await runConfigCommand(['--scope', 'project', 'set', 'custom.key', 'value', '--allow-unknown']); + + process.exitCode = undefined; + consoleLogSpy.mockClear(); + + await runConfigCommand(['--scope', 'project', 'get', 'custom.key', '--allow-unknown']); + + expect(process.exitCode).toBeUndefined(); + expect(consoleLogSpy).toHaveBeenCalledWith('value'); + }); + + it('project scope get rejects unknown keys without --allow-unknown', async () => { + await runConfigCommand(['--scope', 'project', 'set', 'custom.key', 'value', '--allow-unknown']); + + process.exitCode = undefined; + consoleErrorSpy.mockClear(); + + await runConfigCommand(['--scope', 'project', 'get', 'custom.key']); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Project scope only supports profile-related keys') + ); + expect(consoleErrorSpy).toHaveBeenCalledWith('Pass --allow-unknown to bypass this check.'); + }); + + it('project scope unset honors --allow-unknown for unknown keys', async () => { + await runConfigCommand(['--scope', 'project', 'set', 'custom.key', 'value', '--allow-unknown']); + + process.exitCode = undefined; + consoleLogSpy.mockClear(); + + await runConfigCommand(['--scope', 'project', 'unset', 'custom.key', '--allow-unknown']); + + expect(process.exitCode).toBeUndefined(); + expect(consoleLogSpy).toHaveBeenCalledWith('Unset custom.key (reverted to fallback)'); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + const custom = parsed.custom as Record | undefined; + expect(custom?.key).toBeUndefined(); + }); + + it('project scope unset rejects unknown keys without --allow-unknown', async () => { + await runConfigCommand(['--scope', 'project', 'set', 'custom.key', 'value', '--allow-unknown']); + + process.exitCode = undefined; + consoleErrorSpy.mockClear(); + + await runConfigCommand(['--scope', 'project', 'unset', 'custom.key']); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Project scope only supports profile-related keys') + ); + expect(consoleErrorSpy).toHaveBeenCalledWith('Pass --allow-unknown to bypass this check.'); + }); + + it.each([ + ['path'], + ['list'], + ['get', 'profile'], + ['set', 'profile', 'custom'], + ['unset', 'profile'], + ['profile', 'core'], + ])('project scope %s fails when both config.yaml and config.yml exist', async (...commandArgs: string[]) => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n', 'utf-8'); + fs.writeFileSync(path.join(tempDir, 'openspec', 'config.yml'), 'schema: spec-driven\n', 'utf-8'); + + process.exitCode = undefined; + consoleErrorSpy.mockClear(); + + await runConfigCommand(['--scope', 'project', ...commandArgs]); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Both openspec/config.yaml and openspec/config.yml exist') + ); + }); + + it('shared project config resolver builds default config.yaml path without duplicated separators', async () => { + const { resolveProjectConfigPath } = await import('../../src/core/project-config.js'); + + const windowsLikeRootWithTrailingSlash = 'C:\\repo\\sample-project/'; + const resolved = resolveProjectConfigPath(windowsLikeRootWithTrailingSlash); + + const expectedYamlPath = + process.platform === 'win32' + ? 'C:\\repo\\sample-project\\openspec\\config.yaml' + : 'C:\\repo\\sample-project/openspec/config.yaml'; + + expect(resolved.path).toBe(expectedYamlPath); + expect(resolved.exists).toBe(false); + expect(resolved.path).not.toContain('//openspec'); + }); +}); + describe('config command shell completion registry', () => { it('should have config command in registry', async () => { const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); expect(configCmd).toBeDefined(); - expect(configCmd?.description).toBe('View and modify global OpenSpec configuration'); + expect(configCmd?.description).toBe('View and modify OpenSpec configuration'); }); it('should have all config subcommands in registry', async () => { @@ -136,6 +572,19 @@ describe('config command shell completion registry', () => { expect(flagNames).toContain('allow-unknown'); }); + it('should have --allow-unknown flag on get and unset subcommands', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const getCmd = configCmd?.subcommands?.find((s) => s.name === 'get'); + const unsetCmd = configCmd?.subcommands?.find((s) => s.name === 'unset'); + const getFlagNames = getCmd?.flags?.map((f) => f.name) ?? []; + const unsetFlagNames = unsetCmd?.flags?.map((f) => f.name) ?? []; + + expect(getFlagNames).toContain('allow-unknown'); + expect(unsetFlagNames).toContain('allow-unknown'); + }); + it('should have --all and -y flags on reset subcommand', async () => { const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); @@ -151,9 +600,22 @@ describe('config command shell completion registry', () => { const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); - const flagNames = configCmd?.flags?.map((f) => f.name) ?? []; + const scopeFlag = configCmd?.flags?.find((f) => f.name === 'scope'); + + expect(scopeFlag).toBeDefined(); + expect(scopeFlag?.values).toEqual(['global', 'project']); + }); + + it('should include update scope override flag in registry', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const updateCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'update'); + const scopeFlag = updateCmd?.flags?.find((f) => f.name === 'scope'); + const forceFlag = updateCmd?.flags?.find((f) => f.name === 'force'); - expect(flagNames).toContain('scope'); + expect(forceFlag).toBeDefined(); + expect(scopeFlag).toBeDefined(); + expect(scopeFlag?.values).toEqual(['global', 'project']); }); }); diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index c614038aa..f9a077cb3 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -2,6 +2,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { Command } from 'commander'; + +async function runSchemaCommand(args: string[]): Promise { + const { registerSchemaCommand } = await import('../../src/commands/schema.js'); + const program = new Command(); + registerSchemaCommand(program); + await program.parseAsync(['node', 'openspec', 'schema', ...args]); +} describe('schema command', () => { let tempDir: string; @@ -35,6 +43,8 @@ describe('schema command', () => { // Spy on console consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + process.exitCode = undefined; }); afterEach(() => { @@ -51,6 +61,8 @@ describe('schema command', () => { // Reset module cache vi.resetModules(); + + process.exitCode = undefined; }); describe('schema which', () => { @@ -321,6 +333,86 @@ artifacts: expect(schema.artifacts[2].requires).toEqual(['specs']); expect(schema.artifacts[3].requires).toEqual(['design']); }); + + it('should update an existing config.yml without creating config.yaml when set as default', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yml'), + 'schema: spec-driven\n', + 'utf-8' + ); + + await runSchemaCommand([ + 'init', + 'my-workflow', + '--default', + '--description', + 'Test schema', + '--artifacts', + 'proposal', + ]); + + expect(fs.existsSync(path.join(tempDir, 'openspec', 'config.yaml'))).toBe(false); + + const { parse } = await import('yaml'); + const parsed = parse( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yml'), 'utf-8') + ) as Record; + + expect(parsed.schema).toBe('my-workflow'); + }); + + it('should create openspec/config.yaml when no project config exists and set as default', async () => { + await runSchemaCommand([ + 'init', + 'my-workflow', + '--default', + '--description', + 'Test schema', + '--artifacts', + 'proposal', + ]); + + expect(fs.existsSync(path.join(tempDir, 'openspec', 'config.yaml'))).toBe(true); + + const { parse } = await import('yaml'); + const parsed = parse( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed.schema).toBe('my-workflow'); + }); + + it('should fail when both config.yaml and config.yml exist and set as default is requested', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\n', + 'utf-8' + ); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yml'), + 'schema: spec-driven\n', + 'utf-8' + ); + + await runSchemaCommand([ + 'init', + 'my-workflow', + '--default', + '--description', + 'Test schema', + '--artifacts', + 'proposal', + ]); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Both openspec/config.yaml and openspec/config.yml exist. Remove one to continue.' + ) + ); + }); }); describe('JSON output format', () => { diff --git a/test/core/parsers/change-parser.test.ts b/test/core/parsers/change-parser.test.ts index 595f138e3..9198bc68e 100644 --- a/test/core/parsers/change-parser.test.ts +++ b/test/core/parsers/change-parser.test.ts @@ -49,4 +49,41 @@ describe('ChangeParser', () => { expect(change.deltas[0].requirement).toBeDefined(); }); }); + + it('parses delta specs with no-space requirement headers', async () => { + await withTempDir(async (dir) => { + const specsDir = path.join(dir, 'specs', 'foo'); + await fs.mkdir(specsDir, { recursive: true }); + + const content = `# Test Change + +## Why +We need it because reasons that are sufficiently long. + +## What Changes +- **foo:** Add something via bullets (should be overridden)`; + const deltaSpec = `# Delta for Foo + +## ADDED Requirements + +###Requirement: New thing +The system SHALL parse no-space delta headers. + +#### Scenario: basic +Given X +When Y +Then Z`; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec, 'utf8'); + + const parser = new ChangeParser(content, dir); + const change = await parser.parseChangeWithDeltas('test-change'); + + expect(change.deltas).toHaveLength(1); + expect(change.deltas[0].spec).toBe('foo'); + expect(change.deltas[0].operation).toBe('ADDED'); + expect(change.deltas[0].description).toContain('Add requirement:'); + expect(change.deltas[0].requirement?.text).toBe('The system SHALL parse no-space delta headers.'); + }); + }); }); diff --git a/test/core/parsers/markdown-parser.test.ts b/test/core/parsers/markdown-parser.test.ts index 751ab98db..fb8bf36b8 100644 --- a/test/core/parsers/markdown-parser.test.ts +++ b/test/core/parsers/markdown-parser.test.ts @@ -136,6 +136,30 @@ The system SHALL ... expect(spec.requirements[0].scenarios[0].rawText).toContain('- **WHEN** a reader reviews the documentation'); }); + it('should parse requirement headers without a space after ###', () => { + const content = `# Test Spec + +## Purpose +This spec accepts slightly malformed requirement headers consistently. + +## Requirements + +###Requirement: NoSpace +The system SHALL keep this requirement visible. + +#### Scenario: parser reads the requirement +Given a main spec with a no-space requirement header +When the parser reads it +Then the requirement is preserved`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements).toHaveLength(1); + expect(spec.requirements[0].text).toBe('The system SHALL keep this requirement visible.'); + expect(spec.requirements[0].scenarios).toHaveLength(1); + }); + it('should not treat fence-like lines with trailing content as closing fences', () => { const content = `# Test Spec diff --git a/test/core/profile-resolution.test.ts b/test/core/profile-resolution.test.ts new file mode 100644 index 000000000..d8204e882 --- /dev/null +++ b/test/core/profile-resolution.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { resolveEffectiveProfileSettings } from '../../src/core/profile-resolution.js'; + +describe('profile-resolution', () => { + it('uses defaults when project and global values are absent', () => { + const resolved = resolveEffectiveProfileSettings({ + globalConfig: { featureFlags: {} }, + projectConfig: null, + }); + + expect(resolved).toEqual({ + profile: 'core', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'sync', 'archive'], + sources: { + profile: 'default', + delivery: 'default', + workflows: 'default', + }, + }); + }); + + it('applies project values over global values by default', () => { + const resolved = resolveEffectiveProfileSettings({ + globalConfig: { + featureFlags: {}, + profile: 'core', + delivery: 'both', + }, + projectConfig: { + profile: 'custom', + delivery: 'skills', + workflows: ['explore', 'verify'], + }, + }); + + expect(resolved.profile).toBe('custom'); + expect(resolved.delivery).toBe('skills'); + expect(resolved.workflows).toEqual(['explore', 'verify']); + expect(resolved.sources).toEqual({ + profile: 'project', + delivery: 'project', + workflows: 'project', + }); + }); + + it('requires project workflows when project config sets custom profile', () => { + expect(() => + resolveEffectiveProfileSettings({ + globalConfig: { + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: ['new'], + }, + projectConfig: { + profile: 'custom', + }, + }) + ).toThrow( + 'Project config sets profile: custom but does not define workflows. Add workflows to your project config file, or remove the project profile override.' + ); + }); + + it('ignores project config when scope override is global', () => { + const resolved = resolveEffectiveProfileSettings({ + scopeOverride: 'global', + globalConfig: { + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: ['continue'], + }, + projectConfig: { + profile: 'core', + delivery: 'skills', + workflows: ['explore'], + }, + }); + + expect(resolved).toEqual({ + profile: 'custom', + delivery: 'commands', + workflows: ['continue'], + sources: { + profile: 'global', + delivery: 'global', + workflows: 'global', + }, + }); + }); + + it("honors scopeOverride='project' while preserving delivery fallback when project workflows are explicit", () => { + const resolved = resolveEffectiveProfileSettings({ + scopeOverride: 'project', + globalConfig: { + featureFlags: {}, + profile: 'core', + delivery: 'commands', + workflows: ['continue'], + }, + projectConfig: { + profile: 'custom', + workflows: ['explore'], + }, + }); + + expect(resolved).toEqual({ + profile: 'custom', + delivery: 'commands', + workflows: ['explore'], + sources: { + profile: 'project', + delivery: 'global', + workflows: 'project', + }, + }); + }); + + it('applies CLI overrides before project and global values', () => { + const resolved = resolveEffectiveProfileSettings({ + cliOverrides: { + profile: 'custom', + delivery: 'skills', + workflows: ['verify'], + }, + globalConfig: { + featureFlags: {}, + profile: 'core', + delivery: 'both', + }, + projectConfig: { + profile: 'custom', + delivery: 'commands', + workflows: ['explore'], + }, + }); + + expect(resolved).toEqual({ + profile: 'custom', + delivery: 'skills', + workflows: ['verify'], + sources: { + profile: 'cli', + delivery: 'cli', + workflows: 'cli', + }, + }); + }); + + it('derives core workflows from core profile even if workflow keys exist', () => { + const resolved = resolveEffectiveProfileSettings({ + globalConfig: { + featureFlags: {}, + profile: 'core', + delivery: 'both', + workflows: ['verify'], + }, + projectConfig: { + workflows: ['sync'], + }, + }); + + expect(resolved.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(resolved.sources.workflows).toBe('global'); + }); +}); diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 88944659d..5fbb2fb2d 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -3,7 +3,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { + AMBIGUOUS_PROJECT_CONFIG_ERROR, readProjectConfig, + resolveProjectConfigPath, validateConfigRules, suggestSchemas, } from '../../src/core/project-config.js'; @@ -68,6 +70,31 @@ rules: expect(consoleWarnSpy).not.toHaveBeenCalled(); }); + it('should parse valid profile fields from project config', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +profile: custom +delivery: skills +workflows: + - explore + - apply +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + profile: 'custom', + delivery: 'skills', + workflows: ['explore', 'apply'], + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + it('should return partial config when schema is invalid', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); @@ -120,6 +147,77 @@ rules: ); }); + it('should keep valid sibling profile fields when profile is invalid', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +profile: invalid +delivery: commands +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + delivery: 'commands', + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'profile' field") + ); + }); + + it('should keep valid sibling profile fields when delivery is invalid', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +profile: custom +delivery: nonsense +workflows: + - explore +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + profile: 'custom', + workflows: ['explore'], + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'delivery' field") + ); + }); + + it('should keep valid profile and delivery when workflows is invalid', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +profile: custom +delivery: both +workflows: invalid +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + profile: 'custom', + delivery: 'both', + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'workflows' field") + ); + }); + it('should return partial config when rules is not an object', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); @@ -248,30 +346,36 @@ rules: }); }); - it('should handle completely invalid YAML gracefully', () => { + it.each([ + ['config.yaml'], + ['config.yml'], + ])('should handle completely invalid YAML gracefully for %s', (configFileName) => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, 'config.yaml'), 'schema: [unclosed'); + fs.writeFileSync(path.join(configDir, configFileName), 'schema: [unclosed'); const config = readProjectConfig(tempDir); expect(config).toBeNull(); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to parse openspec/config.yaml'), + expect.stringContaining(`Failed to parse openspec/${configFileName}`), expect.anything() ); }); - it('should warn when config is not a YAML object', () => { + it.each([ + ['config.yaml'], + ['config.yml'], + ])('should warn when %s is not a YAML object', (configFileName) => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, 'config.yaml'), '"just a string"'); + fs.writeFileSync(path.join(configDir, configFileName), '"just a string"'); const config = readProjectConfig(tempDir); expect(config).toBeNull(); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('not a valid YAML object') + expect.stringContaining(`openspec/${configFileName} is not a valid YAML object`) ); }); @@ -365,7 +469,42 @@ context: | }); describe('.yml/.yaml precedence', () => { - it('should prefer .yaml when both exist', () => { + it('should default new project config writes to config.yaml when no file exists', () => { + const resolved = resolveProjectConfigPath(tempDir); + + expect(resolved).toEqual({ + path: path.join(tempDir, 'openspec', 'config.yaml'), + exists: false, + }); + }); + + it('should resolve an existing config.yaml file', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.yaml'), 'schema: spec-driven\n'); + + const resolved = resolveProjectConfigPath(tempDir); + + expect(resolved).toEqual({ + path: path.join(configDir, 'config.yaml'), + exists: true, + }); + }); + + it('should resolve an existing config.yml file', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.yml'), 'schema: spec-driven\n'); + + const resolved = resolveProjectConfigPath(tempDir); + + expect(resolved).toEqual({ + path: path.join(configDir, 'config.yml'), + exists: true, + }); + }); + + it('should fail when both .yaml and .yml exist', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync( @@ -377,10 +516,8 @@ context: | 'schema: custom-schema\ncontext: from yml\n' ); - const config = readProjectConfig(tempDir); - - expect(config?.schema).toBe('spec-driven'); - expect(config?.context).toBe('from yaml'); + expect(() => resolveProjectConfigPath(tempDir)).toThrow(AMBIGUOUS_PROJECT_CONFIG_ERROR); + expect(() => readProjectConfig(tempDir)).toThrow(AMBIGUOUS_PROJECT_CONFIG_ERROR); }); it('should use .yml when .yaml does not exist', () => { @@ -397,6 +534,29 @@ context: | expect(config?.context).toBe('from yml'); }); + it('should parse profile fields from .yml files', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yml'), + `schema: custom-schema +profile: custom +delivery: commands +workflows: + - verify +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'custom-schema', + profile: 'custom', + delivery: 'commands', + workflows: ['verify'], + }); + }); + it('should return null when neither .yaml nor .yml exist', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); @@ -545,7 +705,9 @@ rules: it('should suggest close matches using fuzzy matching', () => { const message = suggestSchemas('spec-drven', availableSchemas); // Missing 'i' - expect(message).toContain("Schema 'spec-drven' not found"); + expect(message).toContain( + "Schema 'spec-drven' not found in project config (openspec/config.yaml or existing openspec/config.yml)" + ); expect(message).toContain('Did you mean one of these?'); expect(message).toContain('spec-driven (built-in)'); }); @@ -579,7 +741,7 @@ rules: const message = suggestSchemas('wrong-schema', availableSchemas); expect(message).toContain( - "Fix: Edit openspec/config.yaml and change 'schema: wrong-schema' to a valid schema name" + "Fix: Edit the project config file and change 'schema: wrong-schema' to a valid schema name" ); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7e..2c3c0c503 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1462,6 +1462,249 @@ More user content after markers. consoleSpy.mockRestore(); }); + it('should let project profile settings override global settings by default', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-propose', 'SKILL.md') + )).toBe(false); + }); + + it('should ignore project profile settings when scope override is global', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const scopedUpdateCommand = new UpdateCommand({ scope: 'global' }); + await scopedUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-propose', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-apply-change', 'SKILL.md') + )).toBe(true); + }); + + it('should support explicit project scope with fallback for missing keys', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: ['verify'], + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const scopedUpdateCommand = new UpdateCommand({ scope: 'project' }); + await scopedUpdateCommand.execute(testDir); + + // Delivery falls back to global commands-only when project delivery is absent. + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(false); + + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'explore.md'))).toBe(true); + expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'verify.md'))).toBe(false); + }); + + it('should read project profile settings from config.yml by default', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-propose', 'SKILL.md') + )).toBe(false); + }); + + it('should generate project workflow artifacts by default and switch to global artifacts with --scope global', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['verify'], + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'explore.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-verify-change', 'SKILL.md') + )).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'verify.md') + )).toBe(false); + + const globalUpdateCommand = new UpdateCommand({ scope: 'global' }); + await globalUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-verify-change', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'verify.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'explore.md') + )).toBe(false); + }); + + it('should ignore invalid project custom settings when scope override is global', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const scopedUpdateCommand = new UpdateCommand({ scope: 'global' }); + await scopedUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-propose', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-apply-change', 'SKILL.md') + )).toBe(true); + }); + + it('should fail plain update when project custom profile omits workflows', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yml'), + `schema: spec-driven +profile: custom +` + ); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'Project config sets profile: custom but does not define workflows. Add workflows to your project config file, or remove the project profile override.' + ); + }); + + it('should fail when both openspec/config.yaml and openspec/config.yml exist', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile(path.join(testDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + await fs.writeFile(path.join(testDir, 'openspec', 'config.yml'), 'schema: spec-driven\n'); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'Both openspec/config.yaml and openspec/config.yml exist' + ); + }); + it('should respect skills-only delivery setting', async () => { setMockConfig({ featureFlags: {}, diff --git a/test/core/validation.test.ts b/test/core/validation.test.ts index 972815e51..1a344d4ae 100644 --- a/test/core/validation.test.ts +++ b/test/core/validation.test.ts @@ -321,6 +321,41 @@ The system SHALL do B. ).toBe(true); }); + it('should error on no-space requirement headers that appear after the Requirements section ends', async () => { + const specContent = `# Test Specification + +## Purpose +This specification validates that no-space hidden requirements are rejected too. + +## Requirements + +### Requirement: A +The system SHALL do A. + +#### Scenario: A works +- **WHEN** foo +- **THEN** bar + +## Edge Cases + +###Requirement: B +The system SHALL do B. + +#### Scenario: B works +- **WHEN** baz +- **THEN** qux`; + + const specPath = path.join(testDir, 'spec.md'); + await fs.writeFile(specPath, specContent); + + const report = await new Validator().validateSpec(specPath); + + expect(report.valid).toBe(false); + expect( + report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "###Requirement: B" appears outside')) + ).toBe(true); + }); + it('should ignore delta header examples inside fenced code blocks', async () => { const specContent = `# Test Specification diff --git a/test/specs/source-specs-normalization.test.ts b/test/specs/source-specs-normalization.test.ts index 1169e8a26..679b47abc 100644 --- a/test/specs/source-specs-normalization.test.ts +++ b/test/specs/source-specs-normalization.test.ts @@ -14,7 +14,7 @@ const projectRoot = path.resolve(__dirname, '..', '..'); const specsRoot = path.join(projectRoot, 'openspec', 'specs'); const PURPOSE_PLACEHOLDER_PATTERN = /TBD - created by archiving change .*?\. Update Purpose after archive\./; -const REQUIREMENT_HEADER_PATTERN = /^###\s+Requirement:/gm; +const REQUIREMENT_HEADER_PATTERN = /^###\s*Requirement:/gm; async function getSpecFiles(): Promise { const entries = await fs.readdir(specsRoot, { withFileTypes: true });