diff --git a/.changeset/change-lookup-accepts-existing-names.md b/.changeset/change-lookup-accepts-existing-names.md new file mode 100644 index 0000000000..3f110aee47 --- /dev/null +++ b/.changeset/change-lookup-accepts-existing-names.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': patch +--- + +`--change` now accepts any change name that exists on disk (e.g. date-prefixed names like `2026-07-04-voice-copilot-v1`), matching what `list`, `validate`, and `archive` already resolve. Lookup still rejects unsafe names (path separators, `..`, hidden entries); the kebab-case naming rule still applies when creating a change. diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index f4350a6d9c..122c663018 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -11,7 +11,6 @@ import * as fs from 'fs'; import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; import type { ReferenceIndexEntry } from '../../core/references.js'; import { isRootSelectionError } from '../../core/root-selection.js'; -import { validateChangeName } from '../../utils/change-utils.js'; // ----------------------------------------------------------------------------- // Types @@ -134,6 +133,34 @@ export async function getAvailableChanges( } } +/** + * Validates a change name used to look up an existing change directory. + * Lookup accepts any directory name that `getAvailableChanges` could return + * (the kebab-case convention in `validateChangeName` applies at creation + * time only); it only rejects names that would escape the changes directory + * or address entries `getAvailableChanges` excludes (hidden dirs, archive). + * + * @returns An error message, or undefined if the name is safe to look up + */ +function validateChangeLookupName(changeName: string): string | undefined { + if (changeName === '.' || changeName === '..') { + return 'Change name cannot be a relative path segment'; + } + if (changeName.includes('/') || changeName.includes('\\')) { + return 'Change name cannot contain path separators'; + } + if (changeName.includes('\0')) { + return 'Change name cannot contain null characters'; + } + if (changeName.startsWith('.')) { + return 'Change name cannot start with a dot'; + } + if (changeName === 'archive') { + return "'archive' is reserved for archived changes"; + } + return undefined; +} + /** * Validates that a change exists and returns available changes if not. * Checks directory existence directly to support scaffolded changes (without proposal.md). @@ -159,9 +186,9 @@ export async function validateChangeExists( } // Validate change name format to prevent path traversal - const nameValidation = validateChangeName(changeName); - if (!nameValidation.valid) { - throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`); + const lookupError = validateChangeLookupName(changeName); + if (lookupError) { + throw new Error(`Invalid change name '${changeName}': ${lookupError}`); } // Check directory existence directly diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index a286422a82..9becfff376 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -212,6 +212,33 @@ describe('artifact-workflow CLI commands', () => { const output = getOutput(result); expect(output).toContain('Invalid change name'); }); + + it('rejects hidden directory names', async () => { + const result = await runCLI(['status', '--change', '.hidden'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('Invalid change name'); + }); + + it('rejects the reserved archive directory name', async () => { + await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true }); + + const result = await runCLI(['status', '--change', 'archive'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('Invalid change name'); + }); + + it('accepts digit-leading change names that exist on disk (#1308)', async () => { + await createTestChange('2026-07-04-voice-copilot-v1', ['proposal', 'design']); + + const result = await runCLI(['status', '--change', '2026-07-04-voice-copilot-v1'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('2026-07-04-voice-copilot-v1'); + expect(result.stdout).toContain('2/4 artifacts complete'); + }); }); describe('instructions command', () => { @@ -290,6 +317,17 @@ describe('artifact-workflow CLI commands', () => { expect(output).toContain("Artifact 'unknown-artifact' not found"); expect(output).toContain('Valid artifacts'); }); + + it('accepts digit-leading change names that exist on disk (#1308)', async () => { + await createTestChange('2026-07-04-voice-copilot-v1', ['proposal']); + + const result = await runCLI( + ['instructions', 'design', '--change', '2026-07-04-voice-copilot-v1'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(' {