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
44 changes: 20 additions & 24 deletions src/commands/spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { program } from 'commander';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
import { Validator } from '../core/validation/validator.js';
import type { Spec } from '../core/schemas/index.js';
import type { RootOutput } from '../core/root-selection.js';
import { isInteractive } from '../utils/interactive.js';
import { getSpecIds } from '../utils/item-discovery.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';

const SPECS_DIR = 'openspec/specs';

Expand Down Expand Up @@ -155,37 +156,32 @@ export function registerSpecCommand(rootProgram: typeof program) {
.description('List all available specifications')
.option('--json', 'Output as JSON')
.option('--long', 'Show id and title with counts')
.action((options: { json?: boolean; long?: boolean }) => {
.action(async (options: { json?: boolean; long?: boolean }) => {
try {
if (!existsSync(SPECS_DIR)) {
console.log('No items found');
return;
}

const specs = readdirSync(SPECS_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => {
const specPath = join(SPECS_DIR, dirent.name, 'spec.md');
if (existsSync(specPath)) {
try {
const spec = parseSpecFromFile(specPath, dirent.name);

return {
id: dirent.name,
title: spec.name,
requirementCount: spec.requirements.length
};
} catch {
return {
id: dirent.name,
title: dirent.name,
requirementCount: 0
};
}
const discovered = await discoverSpecFiles(SPECS_DIR);
const specs = discovered
.map(({ id, specFile }) => {
try {
const spec = parseSpecFromFile(specFile, id);

return {
id,
title: spec.name,
requirementCount: spec.requirements.length
};
} catch {
return {
id,
title: id,
requirementCount: 0
};
}
return null;
})
.filter((spec): spec is { id: string; title: string; requirementCount: number } => spec !== null)
.sort((a, b) => a.id.localeCompare(b.id));

if (options.json) {
Expand Down
28 changes: 11 additions & 17 deletions src/core/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
writeUpdatedSpec,
type SpecUpdate,
} from './specs-apply.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';

function isMissingPathError(error: unknown): boolean {
return (
Expand Down Expand Up @@ -266,22 +267,15 @@ export class ArchiveCommand {
// Validate delta-formatted spec files under the change directory if present
const changeSpecsDir = path.join(changeDir, 'specs');
let hasDeltaSpecs = false;
try {
const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true });
for (const c of candidates) {
if (c.isDirectory()) {
try {
const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md');
await fs.access(candidatePath);
const content = await fs.readFile(candidatePath, 'utf-8');
if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) {
hasDeltaSpecs = true;
break;
}
} catch {}
for (const { specFile } of await discoverSpecFiles(changeSpecsDir)) {
try {
const content = await fs.readFile(specFile, 'utf-8');
if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) {
hasDeltaSpecs = true;
break;
}
}
} catch {}
} catch {}
}
if (hasDeltaSpecs) {
const deltaReport = await validator.validateChangeDeltaSpecs(changeDir);
if (!deltaReport.valid) {
Expand Down Expand Up @@ -390,7 +384,7 @@ export class ArchiveCommand {
console.log('\nSpecs to update:');
for (const update of specUpdates) {
const status = update.exists ? 'update' : 'create';
const capability = path.basename(path.dirname(update.target));
const capability = update.id;
console.log(` ${capability}: ${status}`);
}
}
Expand Down Expand Up @@ -440,7 +434,7 @@ export class ArchiveCommand {
// late validation failure really does leave all targets unchanged.
if (!skipValidation) {
for (const p of prepared) {
const specName = path.basename(path.dirname(p.update.target));
const specName = p.update.id;
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
if (!report.valid) {
if (json) {
Expand Down
12 changes: 5 additions & 7 deletions src/core/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { promises as fs } from 'fs';
import path from 'path';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { readFileSync, type Dirent } from 'fs';
import { join } from 'path';
import { MarkdownParser } from './parsers/markdown-parser.js';
import type { RootOutput } from './root-selection.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';

interface ChangeInfo {
name: string;
Expand Down Expand Up @@ -177,9 +177,8 @@ export class ListCommand {
return;
}

const entries = await fs.readdir(specsDir, { withFileTypes: true });
const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
if (specDirs.length === 0) {
const discovered = await discoverSpecFiles(specsDir);
if (discovered.length === 0) {
if (json) {
console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2));
} else {
Expand All @@ -190,10 +189,9 @@ export class ListCommand {

type SpecInfo = { id: string; requirementCount: number };
const specs: SpecInfo[] = [];
for (const id of specDirs) {
const specPath = join(specsDir, id, 'spec.md');
for (const { id, specFile } of discovered) {
try {
const content = readFileSync(specPath, 'utf-8');
const content = readFileSync(specFile, 'utf-8');
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(id);
specs.push({ id, requirementCount: spec.requirements.length });
Expand Down
37 changes: 15 additions & 22 deletions src/core/parsers/change-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { buildCodeFenceMask } from './requirement-text.js';
import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js';
import path from 'path';
import { promises as fs } from 'fs';
import { discoverSpecFiles } from '../../utils/spec-discovery.js';

interface DeltaSection {
operation: DeltaOperation;
Expand Down Expand Up @@ -55,30 +56,22 @@ export class ChangeParser extends MarkdownParser {

private async parseDeltaSpecs(specsDir: string): Promise<Delta[]> {
const deltas: Delta[] = [];

try {
const specDirs = await fs.readdir(specsDir, { withFileTypes: true });

for (const dir of specDirs) {
if (!dir.isDirectory()) continue;

const specName = dir.name;
const specFile = path.join(specsDir, specName, 'spec.md');

try {
const content = await fs.readFile(specFile, 'utf-8');
const specDeltas = this.parseSpecDeltas(specName, content);
deltas.push(...specDeltas);
} catch (error) {
// Spec file might not exist, which is okay
continue;
}

// Discover delta specs recursively so nested layouts like
// specs/<area>/<capability>/spec.md are parsed too (#1353)
const specFiles = await discoverSpecFiles(specsDir);

for (const { id, specFile } of specFiles) {
try {
const content = await fs.readFile(specFile, 'utf-8');
const specDeltas = this.parseSpecDeltas(id, content);
deltas.push(...specDeltas);
} catch (error) {
// Spec file might not be readable, which is okay
continue;
}
} catch (error) {
// Specs directory might not exist, which is okay
return [];
}

return deltas;
}

Expand Down
66 changes: 30 additions & 36 deletions src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ import {
} from './parsers/requirement-blocks.js';
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
import { Validator } from './validation/validator.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';

// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------

export interface SpecUpdate {
/** Capability id relative to the specs root, forward-slash separated (e.g. "web" or "platform/session-layout"). */
id: string;
source: string;
target: string;
exists: boolean;
Expand Down Expand Up @@ -63,38 +66,29 @@ export async function findSpecUpdates(changeDir: string, mainSpecsDir: string):
const updates: SpecUpdate[] = [];
const changeSpecsDir = path.join(changeDir, 'specs');

try {
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });

for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');

try {
await fs.access(specFile);

// Check if target exists
let exists = false;
try {
await fs.access(targetFile);
exists = true;
} catch {
exists = false;
}

updates.push({
source: specFile,
target: targetFile,
exists,
});
} catch {
// Source spec doesn't exist, skip
}
}
// Discover delta specs recursively so nested layouts like
// specs/<area>/<capability>/spec.md merge into the same relative path
// under the main specs directory (#1353)
const discovered = await discoverSpecFiles(changeSpecsDir);

for (const { id, specFile } of discovered) {
const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');

// Check if target exists
let exists = false;
try {
await fs.access(targetFile);
exists = true;
} catch {
exists = false;
}
} catch {
// No specs directory in change

updates.push({
id,
source: specFile,
target: targetFile,
exists,
});
}

return updates;
Expand All @@ -114,7 +108,7 @@ export async function buildUpdatedSpec(

// Parse deltas from the change spec file
const plan = parseDeltaSpec(changeContent);
const specName = path.basename(path.dirname(update.target));
const specName = update.id;

// Pre-validate duplicates within sections
const addedNames = new Set<string>();
Expand Down Expand Up @@ -200,7 +194,7 @@ export async function buildUpdatedSpec(
const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
if (!hasAnyDelta) {
throw new Error(
`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
`Delta parsing found no operations for ${update.id}. ` +
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`
);
}
Expand Down Expand Up @@ -376,7 +370,7 @@ export async function writeUpdatedSpec(

if (options.silent) return;

const specName = path.basename(path.dirname(update.target));
const specName = update.id;
console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`);
if (counts.added) console.log(` + ${counts.added} added`);
if (counts.modified) console.log(` ~ ${counts.modified} modified`);
Expand Down Expand Up @@ -485,7 +479,7 @@ export async function applySpecs(
if (!options.skipValidation) {
const validator = new Validator();
for (const p of prepared) {
const specName = path.basename(path.dirname(p.update.target));
const specName = p.update.id;
const report = await validator.validateSpecContent(specName, p.rebuilt);
if (!report.valid) {
const errors = report.issues
Expand All @@ -502,7 +496,7 @@ export async function applySpecs(
const totals = { added: 0, modified: 0, removed: 0, renamed: 0 };

for (const p of prepared) {
const capability = path.basename(path.dirname(p.update.target));
const capability = p.update.id;

if (!options.dryRun) {
// Write the updated spec
Expand Down
30 changes: 12 additions & 18 deletions src/core/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path';
import chalk from 'chalk';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { MarkdownParser } from './parsers/markdown-parser.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';

export class ViewCommand {
async execute(targetPath: string = '.'): Promise<void> {
Expand Down Expand Up @@ -137,24 +138,17 @@ export class ViewCommand {
}

const specs: Array<{ name: string; requirementCount: number }> = [];
const entries = fs.readdirSync(specsDir, { withFileTypes: true });

for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(specsDir, entry.name, 'spec.md');

if (fs.existsSync(specFile)) {
try {
const content = fs.readFileSync(specFile, 'utf-8');
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(entry.name);
const requirementCount = spec.requirements.length;
specs.push({ name: entry.name, requirementCount });
} catch (error) {
// If spec cannot be parsed, include with 0 count
specs.push({ name: entry.name, requirementCount: 0 });
}
}

for (const { id, specFile } of await discoverSpecFiles(specsDir)) {
try {
const content = fs.readFileSync(specFile, 'utf-8');
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(id);
const requirementCount = spec.requirements.length;
specs.push({ name: id, requirementCount });
} catch (error) {
// If spec cannot be parsed, include with 0 count
specs.push({ name: id, requirementCount: 0 });
}
}

Expand Down
Loading
Loading