diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 9e59a4d48..6437f72e1 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,8 +1,9 @@ import ora from 'ora'; import path from 'path'; import { Validator } from '../core/validation/validator.js'; +import { getChangeDir, resolveCurrentPlanningHomeSync } from '../core/planning-home.js'; import { isInteractive, resolveNoInteractive } from '../utils/interactive.js'; -import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; +import { getChangeDirectoryIds, getSpecIds } from '../utils/item-discovery.js'; import { nearestMatches } from '../utils/match.js'; type ItemType = 'change' | 'spec'; @@ -80,7 +81,11 @@ export class ValidateCommand { if (choice === 'specs') return this.runBulkValidation({ changes: false, specs: true }, opts); // one - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + const planningHome = resolveCurrentPlanningHomeSync(); + const [changes, specs] = await Promise.all([ + getChangeDirectoryIds(planningHome.root, planningHome.changesDir), + getSpecIds(planningHome.root), + ]); const items: { name: string; value: { type: ItemType; id: string } }[] = []; items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } }))); items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } }))); @@ -103,7 +108,11 @@ export class ValidateCommand { } private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + const planningHome = resolveCurrentPlanningHomeSync(); + const [changes, specs] = await Promise.all([ + getChangeDirectoryIds(planningHome.root, planningHome.changesDir), + getSpecIds(planningHome.root), + ]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); @@ -128,9 +137,10 @@ export class ValidateCommand { } private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { + const planningHome = resolveCurrentPlanningHomeSync(); const validator = new Validator(opts.strict); if (type === 'change') { - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); + const changeDir = getChangeDir(planningHome, id); const start = Date.now(); const report = await validator.validateChangeDeltaSpecs(changeDir); const durationMs = Date.now() - start; @@ -139,7 +149,7 @@ export class ValidateCommand { process.exitCode = report.valid ? 0 : 1; return; } - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(planningHome.root, 'openspec', 'specs', id, 'spec.md'); const start = Date.now(); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; @@ -183,9 +193,10 @@ export class ValidateCommand { private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; + const planningHome = resolveCurrentPlanningHomeSync(); const [changeIds, specIds] = await Promise.all([ - scope.changes ? getActiveChangeIds() : Promise.resolve([]), - scope.specs ? getSpecIds() : Promise.resolve([]), + scope.changes ? getChangeDirectoryIds(planningHome.root, planningHome.changesDir) : Promise.resolve([]), + scope.specs ? getSpecIds(planningHome.root) : Promise.resolve([]), ]); const DEFAULT_CONCURRENCY = 6; @@ -197,7 +208,7 @@ export class ValidateCommand { for (const id of changeIds) { queue.push(async () => { const start = Date.now(); - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); + const changeDir = getChangeDir(planningHome, id); const report = await validator.validateChangeDeltaSpecs(changeDir); const durationMs = Date.now() - start; return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs }; @@ -206,7 +217,7 @@ export class ValidateCommand { for (const id of specIds) { queue.push(async () => { const start = Date.now(); - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(planningHome.root, 'openspec', 'specs', id, 'spec.md'); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; return { id, type: 'spec' as const, valid: report.valid, issues: report.issues, durationMs }; diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 47071ed47..56a72505a 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -120,11 +120,8 @@ export class Validator { const emptySectionSpecs: Array<{ path: string; sections: string[] }> = []; try { - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const specName = entry.name; - const specFile = path.join(specsDir, specName, 'spec.md'); + const specFiles = await this.findDeltaSpecFiles(specsDir); + for (const specFile of specFiles) { let content: string | undefined; try { content = await fs.readFile(specFile, 'utf-8'); @@ -133,7 +130,7 @@ export class Validator { } const plan = parseDeltaSpec(content); - const entryPath = `${specName}/spec.md`; + const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile)); const sectionNames: string[] = []; if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements'); if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements'); @@ -287,6 +284,33 @@ export class Validator { }); } + private async findDeltaSpecFiles(specsDir: string): Promise { + const files: string[] = []; + + const walk = async (currentDir: string): Promise => { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + await walk(entryPath); + continue; + } + if (entry.isFile() && entry.name === 'spec.md') { + files.push(entryPath); + } + } + }; + + try { + await walk(specsDir); + } catch { + return []; + } + + return files.sort((a, b) => a.localeCompare(b)); + } + private applySpecRules(spec: Spec, content: string): ValidationIssue[] { const issues: ValidationIssue[] = []; diff --git a/src/utils/item-discovery.ts b/src/utils/item-discovery.ts index 1a86c3aed..8da846e3b 100644 --- a/src/utils/item-discovery.ts +++ b/src/utils/item-discovery.ts @@ -1,27 +1,39 @@ import { promises as fs } from 'fs'; import path from 'path'; -export async function getActiveChangeIds(root: string = process.cwd()): Promise { - const changesPath = path.join(root, 'openspec', 'changes'); +export async function getChangeDirectoryIds( + root: string = process.cwd(), + changesDir: string = path.join(root, 'openspec', 'changes') +): Promise { try { - const entries = await fs.readdir(changesPath, { withFileTypes: true }); - const result: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive') continue; - const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); - try { - await fs.access(proposalPath); - result.push(entry.name); - } catch { - // skip directories without proposal.md - } - } - return result.sort(); + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'archive') + .map((entry) => entry.name) + .sort(); } catch { return []; } } +export async function getActiveChangeIds(root: string = process.cwd()): Promise { + const changesPath = path.join(root, 'openspec', 'changes'); + const entries = await getChangeDirectoryIds(root, changesPath); + const result: string[] = []; + + for (const entry of entries) { + const proposalPath = path.join(changesPath, entry, 'proposal.md'); + try { + await fs.access(proposalPath); + result.push(entry); + } catch { + // skip directories without proposal.md + } + } + + return result.sort(); +} + export async function getSpecIds(root: string = process.cwd()): Promise { const specsPath = path.join(root, 'openspec', 'specs'); const result: string[] = []; diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index b94f72d35..73a37a117 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -144,4 +144,81 @@ describe('top-level validate command', () => { // Should complete without hanging and without prompts expect(result.stderr).not.toContain('What would you like to validate?'); }); + + it('resolves workspace-planning changes from the workspace root and validates nested delta specs', async () => { + const workspaceEnv = { + XDG_DATA_HOME: path.join(testDir, 'data'), + XDG_CONFIG_HOME: path.join(testDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + const api = path.join(testDir, 'linked-api'); + await fs.mkdir(api, { recursive: true }); + + const setup = await runCLI( + [ + 'workspace', + 'setup', + '--no-interactive', + '--json', + '--name', + 'platform', + '--link', + `api=${api}`, + ], + { cwd: testDir, env: workspaceEnv, timeoutMs: 30000 } + ); + expect(setup.exitCode).toBe(0); + + const workspaceRoot = JSON.parse(setup.stdout).workspace.root; + const create = await runCLI( + ['new', 'change', 'nested-workspace-spec', '--goal', 'Plan API login', '--areas', 'api'], + { cwd: workspaceRoot, env: workspaceEnv, timeoutMs: 30000 } + ); + expect(create.exitCode).toBe(0); + + const changeDir = path.join(workspaceRoot, 'changes', 'nested-workspace-spec'); + const specPath = path.join(changeDir, 'specs', 'api', 'login', 'spec.md'); + await fs.mkdir(path.dirname(specPath), { recursive: true }); + await fs.writeFile( + specPath, + [ + '## ADDED Requirements', + '### Requirement: API login SHALL succeed with valid credentials', + 'The workspace plan SHALL capture successful login behavior.', + '', + '#### Scenario: Valid login', + '- **WHEN** credentials are valid', + '- **THEN** login succeeds', + ].join('\n'), + 'utf-8' + ); + + const result = await runCLI(['validate', 'nested-workspace-spec', '--json'], { + cwd: workspaceRoot, + env: workspaceEnv, + timeoutMs: 30000, + }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.items).toHaveLength(1); + expect(json.items[0]).toMatchObject({ + id: 'nested-workspace-spec', + type: 'change', + valid: true, + }); + + const bulk = await runCLI(['validate', '--changes', '--json'], { + cwd: workspaceRoot, + env: workspaceEnv, + timeoutMs: 30000, + }); + expect(bulk.exitCode).toBe(0); + const bulkJson = JSON.parse(bulk.stdout); + expect( + bulkJson.items.some((item: { id: string; type: string; valid: boolean }) => + item.id === 'nested-workspace-spec' && item.type === 'change' && item.valid + ) + ).toBe(true); + }); });