Skip to content
Open
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
29 changes: 20 additions & 9 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 } })));
Expand All @@ -103,7 +108,11 @@ export class ValidateCommand {
}

private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise<void> {
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);

Expand All @@ -128,9 +137,10 @@ export class ValidateCommand {
}

private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> {
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<void> {
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
const planningHome = resolveCurrentPlanningHomeSync();
const [changeIds, specIds] = await Promise.all([
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
scope.changes ? getChangeDirectoryIds(planningHome.root, planningHome.changesDir) : Promise.resolve<string[]>([]),
scope.specs ? getSpecIds(planningHome.root) : Promise.resolve<string[]>([]),
]);

const DEFAULT_CONCURRENCY = 6;
Expand All @@ -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 };
Expand All @@ -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 };
Expand Down
36 changes: 30 additions & 6 deletions src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -287,6 +284,33 @@ export class Validator {
});
}

private async findDeltaSpecFiles(specsDir: string): Promise<string[]> {
const files: string[] = [];

const walk = async (currentDir: string): Promise<void> => {
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[] = [];

Expand Down
42 changes: 27 additions & 15 deletions src/utils/item-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
import { promises as fs } from 'fs';
import path from 'path';

export async function getActiveChangeIds(root: string = process.cwd()): Promise<string[]> {
const changesPath = path.join(root, 'openspec', 'changes');
export async function getChangeDirectoryIds(
root: string = process.cwd(),
changesDir: string = path.join(root, 'openspec', 'changes')
): Promise<string[]> {
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<string[]> {
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<string[]> {
const specsPath = path.join(root, 'openspec', 'specs');
const result: string[] = [];
Expand Down
77 changes: 77 additions & 0 deletions test/commands/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});