Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
32 changes: 28 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,31 @@ 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 hidden entries.
*
* @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';
}
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 +183,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
29 changes: 29 additions & 0 deletions test/commands/artifact-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,24 @@ 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('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 +308,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