From e06ec8d5996ad6dbbbe8d37dfd2cf2db014be8d2 Mon Sep 17 00:00:00 2001 From: TabishB Date: Thu, 9 Jul 2026 00:20:15 +1000 Subject: [PATCH] fix empty store registration --- docs/agent-contract.md | 4 +- docs/cli.md | 7 +- docs/stores-beta/user-guide.md | 8 ++ src/core/archive.ts | 26 +++--- src/core/list.ts | 31 ++++--- src/core/openspec-root.ts | 71 +++++++++++----- src/core/store/operations.ts | 33 ++++++++ test/commands/store-git.test.ts | 96 +++++++++++++++++++++ test/commands/store-root-selection.test.ts | 62 ++++++++++++++ test/commands/store.test.ts | 98 +++++++++++++++++++++- test/core/archive.test.ts | 4 +- test/core/list.test.ts | 22 +++-- test/core/openspec-root.test.ts | 34 +++++++- 13 files changed, 438 insertions(+), 58 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 9f64d66d36..dae386b9f7 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -97,13 +97,13 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis `no_openspec_root`, `no_root_with_registered_stores`, `no_registered_stores`, `unknown_store`, `store_identity_mismatch`, `unhealthy_store_root`, `store_path_not_supported`, `invalid_store_pointer`, `initiative_option_removed`, `areas_option_removed`; pass-through: `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`. ### OpenSpec-root health (error, no fix) -`openspec_store_root_missing`, `openspec_root_missing`, `openspec_config_missing`, `openspec_specs_missing`, `openspec_changes_missing`, `openspec_archive_missing`, plus `_not_directory` variants of each. +`openspec_store_root_missing`, `openspec_store_root_not_directory`, `openspec_root_missing`, `openspec_root_not_directory`, `openspec_config_missing`, `openspec_config_not_file`, `openspec_specs_not_directory`, `openspec_changes_not_directory`, `openspec_archive_not_directory`. During the stores beta, `openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` may be absent in a healthy root; they are only health errors when present but not directories. ### Store registry/identity/state `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`, `store_registry_busy`, `store_not_found`, `no_store_registry`, `store_registry_changed`, `store_metadata_missing`, `store_metadata_id_mismatch`, `store_metadata_invalid`, `store_id_conflict`, `store_path_conflict`, `store_already_registered` (info). ### Store setup/register/remove -`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`. +`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_root_pointer_declared`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`. ### Store git `store_git_init_failed`, `store_git_identity_missing`, `store_git_commit_failed`, `store_git_no_commits` (warning), `store_clone_fragile_directories` (warning), `store_remote_divergence` (info, doctor). diff --git a/docs/cli.md b/docs/cli.md index 07a5daea63..fb591f2bcc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -215,7 +215,12 @@ openspec store setup team-context --path ~/openspec/team-context --no-init-git - ### `openspec store register` -Register an existing local store folder. +Register an existing local store folder. During the stores beta, a root may be +registered before any changes exist, specs have been applied, or changes have +been archived; in that case `openspec/changes/`, `openspec/specs/`, and +`openspec/changes/archive/` may be absent until normal commands create them. +A config-only repo that declares `store: ` remains a pointer to another +store and is not registered as a store root unless that pointer is removed. ```bash openspec store register [path] [options] diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 78433ef4d0..3711777e15 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -308,6 +308,14 @@ tells you which case you're in. - **No sync, ever — by design.** OpenSpec never clones, pulls, or pushes. A stale checkout shows stale specs until *you* pull; references are indexed live from whatever is on disk. +- **Empty planning folders can be absent.** A new store may not have + `openspec/changes/`, `openspec/specs/`, or `openspec/changes/archive/` in Git + yet. That is accepted during the beta; those folders appear once normal + commands create files for them. +- **Pointer repos stay pointers.** A config-only repo whose + `openspec/config.yaml` declares `store: ` is treated as externalized + planning, not as a store checkout to register. Remove the `store:` line first + if you intentionally want to convert that repo into a local store root. - **Some commands stay where they are.** `view`, `templates`, `schemas`, and the deprecated noun forms (`openspec change show`, ...) act on the current directory only — no `--store`. diff --git a/src/core/archive.ts b/src/core/archive.ts index 3e9bf80025..850d4cd5ba 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -19,6 +19,15 @@ import { type SpecUpdate, } from './specs-apply.js'; +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + async function listActiveChangeNames(changesDir: string): Promise { try { const entries = await fs.readdir(changesDir, { withFileTypes: true }); @@ -26,7 +35,8 @@ async function listActiveChangeNames(changesDir: string): Promise { .filter((entry) => entry.isDirectory() && entry.name !== 'archive') .map((entry) => entry.name) .sort(); - } catch { + } catch (error) { + if (!isMissingPathError(error)) throw error; return []; } } @@ -192,13 +202,6 @@ export class ArchiveCommand { const archiveDir = root.archiveDir; const mainSpecsDir = root.specsDir; - // Check if changes directory exists - try { - await fs.access(changesDir); - } catch { - throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); - } - // Get change name interactively if not provided if (!changeName) { if (json) { @@ -523,12 +526,7 @@ export class ArchiveCommand { private async selectChange(changesDir: string): Promise { const { select } = await import('@inquirer/prompts'); - // Get all directories in changes (excluding archive) - const entries = await fs.readdir(changesDir, { withFileTypes: true }); - const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') - .map(entry => entry.name) - .sort(); + const changeDirs = await listActiveChangeNames(changesDir); if (changeDirs.length === 0) { console.log('No active changes found.'); diff --git a/src/core/list.ts b/src/core/list.ts index 8e4d0a9ed7..0c19048e21 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; -import { readFileSync } from 'fs'; +import { readFileSync, type Dirent } from 'fs'; import { join } from 'path'; import { MarkdownParser } from './parsers/markdown-parser.js'; import type { RootOutput } from './root-selection.js'; @@ -19,6 +19,24 @@ interface ListOptions { root?: RootOutput; } +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +async function readChangeDirectoryEntries(changesDir: string): Promise { + try { + return await fs.readdir(changesDir, { withFileTypes: true }); + } catch (error) { + if (isMissingPathError(error)) return []; + throw error; + } +} + /** * Get the most recent modification time of any file in a directory (recursive). * Falls back to the directory's own mtime if no files are found. @@ -83,15 +101,8 @@ export class ListCommand { if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); - // Check if changes directory exists - try { - await fs.access(changesDir); - } catch { - throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); - } - // Get all directories in changes (excluding archive) - const entries = await fs.readdir(changesDir, { withFileTypes: true }); + const entries = await readChangeDirectoryEntries(changesDir); const changeDirs = entries .filter(entry => entry.isDirectory() && entry.name !== 'archive') .map(entry => entry.name); @@ -207,4 +218,4 @@ export class ListCommand { console.log(`${padding}${padded} requirements ${spec.requirementCount}`); } } -} \ No newline at end of file +} diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index c64c2912f4..d65882ee21 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -17,8 +17,8 @@ export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep'; -// Git cannot track empty directories, so clones of a fresh store would lose -// these and fail root-health checks. Anchored at setup time. +// Git cannot track empty directories, so setup anchors otherwise-empty +// conventional store directories for teammates who clone the repo later. export const ANCHORED_OPENSPEC_DIRS = [OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR] as const; type PathKind = 'missing' | 'directory' | 'file' | 'other'; @@ -99,6 +99,28 @@ function missingDirectoryDiagnostic( return makeStoreDiagnostic('error', code, message, { target }); } +type OptionalPlanningDirectoryKey = 'specs' | 'changes' | 'archive'; + +async function inspectOptionalPlanningDirectory( + inspection: OpenSpecRootInspection, + storeRoot: string, + key: OptionalPlanningDirectoryKey, + relativePath: string, + notDirectoryCode: string, + target: string +): Promise { + const kind = await pathKind(path.join(storeRoot, relativePath)); + inspection[key] = { present: kind === 'directory' }; + if (kind === 'directory' || kind === 'missing') return kind; + + inspection.diagnostics.push(missingDirectoryDiagnostic( + notDirectoryCode, + `${relativePath}/ exists but is not a directory.`, + target + )); + return kind; +} + export async function inspectOpenSpecRoot(storeRoot: string): Promise { const rootKind = await pathKind(storeRoot); const inspection = unresolvedInspection(); @@ -166,28 +188,39 @@ export async function inspectOpenSpecRoot(storeRoot: string): Promise { expect(fs.existsSync(path.join(cloneRoot, 'workspace.yaml'))).toBe(false); }); + it('registers a clone before any changes exist', async () => { + const storeRoot = mkdir('empty-team-context'); + const cloneRoot = path.join(tempDir, 'empty-team-clone'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + const gitExecEnv = { ...process.env, ...gitEnv }; + const teammateEnv = { + ...gitEnv, + XDG_DATA_HOME: path.join(tempDir, 'empty-teammate-data'), + XDG_CONFIG_HOME: path.join(tempDir, 'empty-teammate-config'), + }; + fs.mkdirSync(path.join(storeRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'empty-team-context' }); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'initialize empty store'], { + cwd: storeRoot, + env: gitExecEnv, + stdio: 'ignore', + }); + + execFileSync('git', ['clone', storeRoot, cloneRoot], { + env: gitExecEnv, + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'specs'))).toBe(false); + + const registered = await runCLI(['store', 'register', cloneRoot, '--json'], { + cwd: tempDir, + env: teammateEnv, + }); + expect(registered.exitCode).toBe(0); + expect(parseJson(registered).store.id).toBe('empty-team-context'); + }); + + it('registers a clone with active changes before specs or archive exist', async () => { + const storeRoot = mkdir('planned-context'); + const cloneRoot = path.join(tempDir, 'planned-clone'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + const gitExecEnv = { ...process.env, ...gitEnv }; + const teammateEnv = { + ...gitEnv, + XDG_DATA_HOME: path.join(tempDir, 'teammate-data'), + XDG_CONFIG_HOME: path.join(tempDir, 'teammate-config'), + }; + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes', 'add-widget'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'proposal.md'), + '# Proposal\n' + ); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'tasks.md'), + '# Tasks\n' + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'planned-context' }); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'draft changes'], { + cwd: storeRoot, + env: gitExecEnv, + stdio: 'ignore', + }); + + const committedFiles = execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'], { + cwd: storeRoot, + }) + .toString() + .trim() + .split('\n') + .sort(); + expect(committedFiles).toEqual([ + '.openspec-store/store.yaml', + 'openspec/changes/add-widget/proposal.md', + 'openspec/changes/add-widget/tasks.md', + 'openspec/config.yaml', + ]); + expect(committedFiles).not.toContain('openspec/specs/.gitkeep'); + expect(committedFiles).not.toContain('openspec/changes/archive/.gitkeep'); + + execFileSync('git', ['clone', storeRoot, cloneRoot], { + env: gitExecEnv, + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'changes', 'archive'))).toBe(false); + + const registered = await runCLI(['store', 'register', cloneRoot, '--json'], { + cwd: tempDir, + env: teammateEnv, + }); + expect(registered.exitCode).toBe(0); + expect(parseJson(registered).store.id).toBe('planned-context'); + }); + it('keeps pre-staged user files out of the setup commit', async () => { const storeRoot = mkdir('staged-context'); const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 082e5cdb1c..e50147e0d9 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -181,6 +181,37 @@ describe('store root selection for normal commands', () => { expect(json.root.store_id).toBe('team-context'); }); + it('lists an empty team store before any changes exist', async () => { + const blankStoreRoot = path.join(tempDir, 'stores', 'blank-context'); + fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(blankStoreRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + await writeStoreMetadataState(blankStoreRoot, { + version: 1, + id: 'blank-context', + }); + const registered = await runCLI( + ['store', 'register', blankStoreRoot, '--json'], + { cwd: appRepo, env } + ); + expect(registered.exitCode).toBe(0); + + const result = await runCLI(['list', '--json', '--store', 'blank-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.changes).toEqual([]); + expect(json.root).toEqual({ + path: fs.realpathSync.native(blankStoreRoot), + source: 'store', + store_id: 'blank-context', + }); + }); + it('reads, validates, shows, and reports status in the selected store', async () => { createChange(storeRoot, 'store-change'); @@ -506,6 +537,37 @@ describe('store root selection for normal commands', () => { expect(json.status[0].code).toBe('archive_change_name_required'); }); + it('reports no active changes for a selected empty store without init guidance', async () => { + const blankStoreRoot = path.join(tempDir, 'stores', 'archive-blank-context'); + fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(blankStoreRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + await writeStoreMetadataState(blankStoreRoot, { + version: 1, + id: 'archive-blank-context', + }); + const registered = await runCLI( + ['store', 'register', blankStoreRoot, '--json'], + { cwd: appRepo, env } + ); + expect(registered.exitCode).toBe(0); + + const result = await runCLI( + ['archive', 'missing-change', '--store', 'archive-blank-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0]).toEqual(expect.objectContaining({ + code: 'archive_change_not_found', + message: "Change 'missing-change' not found. No active changes exist in this root.", + })); + }); + it('reports validation failures as diagnostics without stdout prose', async () => { createChange(storeRoot, 'bad-change', { deltaSpec: INVALID_DELTA_SPEC }); diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index 171ac0a6c6..41a17a6377 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -370,6 +370,50 @@ describe('store command', () => { expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); }); + it('refuses to convert a config-only store pointer repo into a store', async () => { + const pointerRoot = mkdir('app-repo'); + fs.mkdirSync(path.join(pointerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRoot, 'openspec', 'config.yaml'), 'store: team-context\n'); + + const setup = await runCLI( + ['store', 'setup', 'app-context', '--path', pointerRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + const register = await runCLI( + ['store', 'register', pointerRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(setup.exitCode).toBe(1); + expect(parseJson(setup).status[0]).toEqual(expect.objectContaining({ + code: 'store_root_pointer_declared', + })); + expect(register.exitCode).toBe(1); + expect(parseJson(register).status[0]).toEqual(expect.objectContaining({ + code: 'store_root_pointer_declared', + })); + expect(fs.existsSync(path.join(pointerRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(pointerRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(getStoreMetadataPath(pointerRoot))).toBe(false); + }); + + it('refuses malformed config-only store pointer repos before registering', async () => { + const pointerRoot = mkdir('bad-app-repo'); + fs.mkdirSync(path.join(pointerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRoot, 'openspec', 'config.yaml'), 'store: [team-context]\n'); + + const result = await runCLI( + ['store', 'register', pointerRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual(expect.objectContaining({ + code: 'invalid_store_pointer', + })); + expect(fs.existsSync(getStoreMetadataPath(pointerRoot))).toBe(false); + }); + it('rejects explicit setup paths inside an existing Git repo in non-interactive mode', async () => { const repoRoot = mkdir('repo'); execFileSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }); @@ -515,6 +559,55 @@ describe('store command', () => { expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe('keep\n'); }); + it('registers a team store before any changes exist', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.store.id).toBe('team-context'); + expect(payload.created_files).toEqual([]); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + + it('registers a store with active changes before specs or archive exist', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes', 'add-widget'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'proposal.md'), + '# Proposal\n' + ); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.store.id).toBe('team-context'); + expect(payload.created_files).toEqual([]); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + it('requires confirmation before registering a healthy root without identity', async () => { const storeRoot = mkdir('team-context'); createHealthyOpenSpecRoot(storeRoot); @@ -979,6 +1072,7 @@ describe('store command', () => { const storeRoot = mkdir('team-context'); fs.mkdirSync(path.join(storeRoot, 'openspec', 'specs'), { recursive: true }); fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'not a dir\n'); fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); await writeStoreRegistryState( @@ -1006,10 +1100,10 @@ describe('store command', () => { expect(store.openspec_root.archive.present).toBe(false); expect(store.openspec_root.status[0]).toEqual( expect.objectContaining({ - code: 'openspec_archive_missing', + code: 'openspec_archive_not_directory', }) ); - expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'utf-8')).toBe('not a dir\n'); }); it('register errors are terminal: one-checkout rule, no circular fix texts', async () => { diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 6724b8d530..0d039f2026 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -1078,13 +1078,13 @@ The system SHALL do the thing differently. }); describe('error handling', () => { - it('should throw error when openspec directory does not exist', async () => { + it('should report no active changes when openspec directory does not exist', async () => { // Remove openspec directory await fs.rm(path.join(tempDir, 'openspec'), { recursive: true }); await expect( archiveCommand.execute('any-change', { yes: true }) - ).rejects.toThrow("No OpenSpec changes directory found. Run 'openspec init' first."); + ).rejects.toThrow("Change 'any-change' not found. No active changes exist in this root."); }); }); diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 5a678919af..096e46a1d8 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -31,12 +31,12 @@ describe('ListCommand', () => { }); describe('execute', () => { - it('should handle missing openspec/changes directory', async () => { + it('should treat a missing openspec/changes directory as no active changes', async () => { const listCommand = new ListCommand(); - - await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow( - "No OpenSpec changes directory found. Run 'openspec init' first." - ); + + await listCommand.execute(tempDir, 'changes'); + + expect(logOutput).toEqual(['No active changes found.']); }); it('should handle empty changes directory', async () => { @@ -49,6 +49,16 @@ describe('ListCommand', () => { expect(logOutput).toEqual(['No active changes found.']); }); + it('should not report a malformed openspec/changes path as empty', async () => { + await fs.mkdir(path.join(tempDir, 'openspec'), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'openspec', 'changes'), 'not a directory\n'); + + const listCommand = new ListCommand(); + + await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow(); + expect(logOutput).toEqual([]); + }); + it('should exclude archive directory', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true }); @@ -162,4 +172,4 @@ Regular text that should be ignored expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts index b0d27da485..d2059f31e0 100644 --- a/test/core/openspec-root.test.ts +++ b/test/core/openspec-root.test.ts @@ -64,12 +64,42 @@ describe('OpenSpec root helper', () => { expect(inspection.healthy).toBe(false); expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ 'openspec_config_missing', - 'openspec_specs_missing', - 'openspec_archive_missing', ]); expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(false); }); + it('accepts roots before changes, applied specs, or archives exist', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection).toEqual(expect.objectContaining({ + healthy: true, + specs: { present: false }, + changes: { present: false }, + archive: { present: false }, + diagnostics: [], + })); + }); + + it('reports malformed optional planning paths without throwing', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + fs.writeFileSync(path.join(root, 'openspec', 'changes'), 'not a directory\n'); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection.healthy).toBe(false); + expect(inspection.changes).toEqual({ present: false }); + expect(inspection.archive).toEqual({ present: false }); + expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'openspec_changes_not_directory', + ]); + }); + it('ensures the default root shape and records created paths', async () => { const root = path.join(tempDir, 'store');