Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/change-lookup-accepts-existing-names.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 31 additions & 4 deletions src/commands/workflow/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Validates that a change exists and returns available changes if not.
* Checks directory existence directly to support scaffolded changes (without proposal.md).
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions test/commands/artifact-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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('<artifact id="design"');
});
});

describe('templates command', () => {
Expand Down
Loading