@fission-ai/openspec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/bin/openspec.js +3 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +240 -0
- package/dist/commands/change.d.ts +35 -0
- package/dist/commands/change.js +276 -0
- package/dist/commands/show.d.ts +14 -0
- package/dist/commands/show.js +131 -0
- package/dist/commands/spec.d.ts +15 -0
- package/dist/commands/spec.js +224 -0
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +275 -0
- package/dist/core/archive.d.ts +15 -0
- package/dist/core/archive.js +529 -0
- package/dist/core/config.d.ts +14 -0
- package/dist/core/config.js +12 -0
- package/dist/core/configurators/base.d.ts +7 -0
- package/dist/core/configurators/base.js +2 -0
- package/dist/core/configurators/claude.d.ts +8 -0
- package/dist/core/configurators/claude.js +15 -0
- package/dist/core/configurators/registry.d.ts +9 -0
- package/dist/core/configurators/registry.js +22 -0
- package/dist/core/converters/json-converter.d.ts +6 -0
- package/dist/core/converters/json-converter.js +48 -0
- package/dist/core/diff.d.ts +11 -0
- package/dist/core/diff.js +193 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/init.d.ts +10 -0
- package/dist/core/init.js +109 -0
- package/dist/core/list.d.ts +4 -0
- package/dist/core/list.js +89 -0
- package/dist/core/parsers/change-parser.d.ts +13 -0
- package/dist/core/parsers/change-parser.js +192 -0
- package/dist/core/parsers/markdown-parser.d.ts +21 -0
- package/dist/core/parsers/markdown-parser.js +183 -0
- package/dist/core/parsers/requirement-blocks.d.ts +31 -0
- package/dist/core/parsers/requirement-blocks.js +173 -0
- package/dist/core/schemas/base.schema.d.ts +13 -0
- package/dist/core/schemas/base.schema.js +13 -0
- package/dist/core/schemas/change.schema.d.ts +73 -0
- package/dist/core/schemas/change.schema.js +31 -0
- package/dist/core/schemas/index.d.ts +4 -0
- package/dist/core/schemas/index.js +4 -0
- package/dist/core/schemas/spec.schema.d.ts +18 -0
- package/dist/core/schemas/spec.schema.js +15 -0
- package/dist/core/templates/claude-template.d.ts +2 -0
- package/dist/core/templates/claude-template.js +96 -0
- package/dist/core/templates/index.d.ts +11 -0
- package/dist/core/templates/index.js +21 -0
- package/dist/core/templates/project-template.d.ts +8 -0
- package/dist/core/templates/project-template.js +32 -0
- package/dist/core/templates/readme-template.d.ts +2 -0
- package/dist/core/templates/readme-template.js +519 -0
- package/dist/core/update.d.ts +4 -0
- package/dist/core/update.js +47 -0
- package/dist/core/validation/constants.d.ts +34 -0
- package/dist/core/validation/constants.js +40 -0
- package/dist/core/validation/types.d.ts +18 -0
- package/dist/core/validation/types.js +2 -0
- package/dist/core/validation/validator.d.ts +32 -0
- package/dist/core/validation/validator.js +355 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +83 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/interactive.d.ts +2 -0
- package/dist/utils/interactive.js +8 -0
- package/dist/utils/item-discovery.d.ts +3 -0
- package/dist/utils/item-discovery.js +49 -0
- package/dist/utils/match.d.ts +3 -0
- package/dist/utils/match.js +22 -0
- package/dist/utils/task-progress.d.ts +8 -0
- package/dist/utils/task-progress.js +36 -0
- package/package.json +68 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class ShowCommand {
|
|
2
|
+
execute(itemName?: string, options?: {
|
|
3
|
+
json?: boolean;
|
|
4
|
+
type?: string;
|
|
5
|
+
noInteractive?: boolean;
|
|
6
|
+
[k: string]: any;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
private normalizeType;
|
|
9
|
+
private runInteractiveByType;
|
|
10
|
+
private showDirect;
|
|
11
|
+
private printNonInteractiveHint;
|
|
12
|
+
private warnIrrelevantFlags;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=show.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { isInteractive } from '../utils/interactive.js';
|
|
3
|
+
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
|
|
4
|
+
import { ChangeCommand } from './change.js';
|
|
5
|
+
import { SpecCommand } from './spec.js';
|
|
6
|
+
import { nearestMatches } from '../utils/match.js';
|
|
7
|
+
const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']);
|
|
8
|
+
const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
|
|
9
|
+
export class ShowCommand {
|
|
10
|
+
async execute(itemName, options = {}) {
|
|
11
|
+
const interactive = isInteractive(options.noInteractive);
|
|
12
|
+
const typeOverride = this.normalizeType(options.type);
|
|
13
|
+
if (!itemName) {
|
|
14
|
+
if (interactive) {
|
|
15
|
+
const type = await select({
|
|
16
|
+
message: 'What would you like to show?',
|
|
17
|
+
choices: [
|
|
18
|
+
{ name: 'Change', value: 'change' },
|
|
19
|
+
{ name: 'Spec', value: 'spec' },
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
await this.runInteractiveByType(type, options);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.printNonInteractiveHint();
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
await this.showDirect(itemName, { typeOverride, options });
|
|
30
|
+
}
|
|
31
|
+
normalizeType(value) {
|
|
32
|
+
if (!value)
|
|
33
|
+
return undefined;
|
|
34
|
+
const v = value.toLowerCase();
|
|
35
|
+
if (v === 'change' || v === 'spec')
|
|
36
|
+
return v;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
async runInteractiveByType(type, options) {
|
|
40
|
+
if (type === 'change') {
|
|
41
|
+
const changes = await getActiveChangeIds();
|
|
42
|
+
if (changes.length === 0) {
|
|
43
|
+
console.error('No changes found.');
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const picked = await select({ message: 'Pick a change', choices: changes.map(id => ({ name: id, value: id })) });
|
|
48
|
+
const cmd = new ChangeCommand();
|
|
49
|
+
await cmd.show(picked, options);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const specs = await getSpecIds();
|
|
53
|
+
if (specs.length === 0) {
|
|
54
|
+
console.error('No specs found.');
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const picked = await select({ message: 'Pick a spec', choices: specs.map(id => ({ name: id, value: id })) });
|
|
59
|
+
const cmd = new SpecCommand();
|
|
60
|
+
await cmd.show(picked, options);
|
|
61
|
+
}
|
|
62
|
+
async showDirect(itemName, params) {
|
|
63
|
+
// Optimize lookups when type is pre-specified
|
|
64
|
+
let isChange = false;
|
|
65
|
+
let isSpec = false;
|
|
66
|
+
let changes = [];
|
|
67
|
+
let specs = [];
|
|
68
|
+
if (params.typeOverride === 'change') {
|
|
69
|
+
changes = await getActiveChangeIds();
|
|
70
|
+
isChange = changes.includes(itemName);
|
|
71
|
+
}
|
|
72
|
+
else if (params.typeOverride === 'spec') {
|
|
73
|
+
specs = await getSpecIds();
|
|
74
|
+
isSpec = specs.includes(itemName);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
[changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
|
|
78
|
+
isChange = changes.includes(itemName);
|
|
79
|
+
isSpec = specs.includes(itemName);
|
|
80
|
+
}
|
|
81
|
+
const resolvedType = params.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);
|
|
82
|
+
if (!resolvedType) {
|
|
83
|
+
console.error(`Unknown item '${itemName}'`);
|
|
84
|
+
const suggestions = nearestMatches(itemName, [...changes, ...specs]);
|
|
85
|
+
if (suggestions.length)
|
|
86
|
+
console.error(`Did you mean: ${suggestions.join(', ')}?`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!params.typeOverride && isChange && isSpec) {
|
|
91
|
+
console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);
|
|
92
|
+
console.error('Pass --type change|spec, or use: openspec change show / openspec spec show');
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.warnIrrelevantFlags(resolvedType, params.options);
|
|
97
|
+
if (resolvedType === 'change') {
|
|
98
|
+
const cmd = new ChangeCommand();
|
|
99
|
+
await cmd.show(itemName, params.options);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const cmd = new SpecCommand();
|
|
103
|
+
await cmd.show(itemName, params.options);
|
|
104
|
+
}
|
|
105
|
+
printNonInteractiveHint() {
|
|
106
|
+
console.error('Nothing to show. Try one of:');
|
|
107
|
+
console.error(' openspec show <item>');
|
|
108
|
+
console.error(' openspec change show');
|
|
109
|
+
console.error(' openspec spec show');
|
|
110
|
+
console.error('Or run in an interactive terminal.');
|
|
111
|
+
}
|
|
112
|
+
warnIrrelevantFlags(type, options) {
|
|
113
|
+
const irrelevant = [];
|
|
114
|
+
if (type === 'change') {
|
|
115
|
+
for (const k of SPEC_FLAG_KEYS)
|
|
116
|
+
if (k in options)
|
|
117
|
+
irrelevant.push(k);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
for (const k of CHANGE_FLAG_KEYS)
|
|
121
|
+
if (k in options)
|
|
122
|
+
irrelevant.push(k);
|
|
123
|
+
}
|
|
124
|
+
if (irrelevant.length > 0) {
|
|
125
|
+
console.error(`Warning: Ignoring flags not applicable to ${type}: ${irrelevant.join(', ')}`);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=show.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { program } from 'commander';
|
|
2
|
+
interface ShowOptions {
|
|
3
|
+
json?: boolean;
|
|
4
|
+
requirements?: boolean;
|
|
5
|
+
scenarios?: boolean;
|
|
6
|
+
requirement?: string;
|
|
7
|
+
noInteractive?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare class SpecCommand {
|
|
10
|
+
private SPECS_DIR;
|
|
11
|
+
show(specId?: string, options?: ShowOptions): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function registerSpecCommand(rootProgram: typeof program): import("commander").Command;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=spec.d.ts.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
|
|
4
|
+
import { Validator } from '../core/validation/validator.js';
|
|
5
|
+
import { select } from '@inquirer/prompts';
|
|
6
|
+
import { isInteractive } from '../utils/interactive.js';
|
|
7
|
+
import { getSpecIds } from '../utils/item-discovery.js';
|
|
8
|
+
const SPECS_DIR = 'openspec/specs';
|
|
9
|
+
function parseSpecFromFile(specPath, specId) {
|
|
10
|
+
const content = readFileSync(specPath, 'utf-8');
|
|
11
|
+
const parser = new MarkdownParser(content);
|
|
12
|
+
return parser.parseSpec(specId);
|
|
13
|
+
}
|
|
14
|
+
function validateRequirementIndex(spec, requirementOpt) {
|
|
15
|
+
if (!requirementOpt)
|
|
16
|
+
return undefined;
|
|
17
|
+
const index = Number.parseInt(requirementOpt, 10);
|
|
18
|
+
if (!Number.isInteger(index) || index < 1 || index > spec.requirements.length) {
|
|
19
|
+
throw new Error(`Requirement ${requirementOpt} not found`);
|
|
20
|
+
}
|
|
21
|
+
return index - 1; // convert to 0-based
|
|
22
|
+
}
|
|
23
|
+
function filterSpec(spec, options) {
|
|
24
|
+
const requirementIndex = validateRequirementIndex(spec, options.requirement);
|
|
25
|
+
const includeScenarios = options.scenarios !== false && !options.requirements;
|
|
26
|
+
const filteredRequirements = (requirementIndex !== undefined
|
|
27
|
+
? [spec.requirements[requirementIndex]]
|
|
28
|
+
: spec.requirements).map(req => ({
|
|
29
|
+
text: req.text,
|
|
30
|
+
scenarios: includeScenarios ? req.scenarios : [],
|
|
31
|
+
}));
|
|
32
|
+
const metadata = spec.metadata ?? { version: '1.0.0', format: 'openspec' };
|
|
33
|
+
return {
|
|
34
|
+
name: spec.name,
|
|
35
|
+
overview: spec.overview,
|
|
36
|
+
requirements: filteredRequirements,
|
|
37
|
+
metadata,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Print the raw markdown content for a spec file without any formatting.
|
|
42
|
+
* Raw-first behavior ensures text mode is a passthrough for deterministic output.
|
|
43
|
+
*/
|
|
44
|
+
function printSpecTextRaw(specPath) {
|
|
45
|
+
const content = readFileSync(specPath, 'utf-8');
|
|
46
|
+
console.log(content);
|
|
47
|
+
}
|
|
48
|
+
export class SpecCommand {
|
|
49
|
+
SPECS_DIR = 'openspec/specs';
|
|
50
|
+
async show(specId, options = {}) {
|
|
51
|
+
if (!specId) {
|
|
52
|
+
const canPrompt = isInteractive(options?.noInteractive);
|
|
53
|
+
const specIds = await getSpecIds();
|
|
54
|
+
if (canPrompt && specIds.length > 0) {
|
|
55
|
+
specId = await select({
|
|
56
|
+
message: 'Select a spec to show',
|
|
57
|
+
choices: specIds.map(id => ({ name: id, value: id })),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
throw new Error('Missing required argument <spec-id>');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const specPath = join(this.SPECS_DIR, specId, 'spec.md');
|
|
65
|
+
if (!existsSync(specPath)) {
|
|
66
|
+
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
|
|
67
|
+
}
|
|
68
|
+
if (options.json) {
|
|
69
|
+
if (options.requirements && options.requirement) {
|
|
70
|
+
throw new Error('Options --requirements and --requirement cannot be used together');
|
|
71
|
+
}
|
|
72
|
+
const parsed = parseSpecFromFile(specPath, specId);
|
|
73
|
+
const filtered = filterSpec(parsed, options);
|
|
74
|
+
const output = {
|
|
75
|
+
id: specId,
|
|
76
|
+
title: parsed.name,
|
|
77
|
+
overview: parsed.overview,
|
|
78
|
+
requirementCount: filtered.requirements.length,
|
|
79
|
+
requirements: filtered.requirements,
|
|
80
|
+
metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' },
|
|
81
|
+
};
|
|
82
|
+
console.log(JSON.stringify(output, null, 2));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
printSpecTextRaw(specPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function registerSpecCommand(rootProgram) {
|
|
89
|
+
const specCommand = rootProgram
|
|
90
|
+
.command('spec')
|
|
91
|
+
.description('Manage and view OpenSpec specifications');
|
|
92
|
+
// Deprecation notice for noun-based commands
|
|
93
|
+
specCommand.hook('preAction', () => {
|
|
94
|
+
console.error('Warning: The "openspec spec ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec show", "openspec validate --specs").');
|
|
95
|
+
});
|
|
96
|
+
specCommand
|
|
97
|
+
.command('show [spec-id]')
|
|
98
|
+
.description('Display a specific specification')
|
|
99
|
+
.option('--json', 'Output as JSON')
|
|
100
|
+
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
|
|
101
|
+
.option('--no-scenarios', 'JSON only: Exclude scenario content')
|
|
102
|
+
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
|
|
103
|
+
.option('--no-interactive', 'Disable interactive prompts')
|
|
104
|
+
.action(async (specId, options) => {
|
|
105
|
+
try {
|
|
106
|
+
const cmd = new SpecCommand();
|
|
107
|
+
await cmd.show(specId, options);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
specCommand
|
|
115
|
+
.command('list')
|
|
116
|
+
.description('List all available specifications')
|
|
117
|
+
.option('--json', 'Output as JSON')
|
|
118
|
+
.option('--long', 'Show id and title with counts')
|
|
119
|
+
.action((options) => {
|
|
120
|
+
try {
|
|
121
|
+
if (!existsSync(SPECS_DIR)) {
|
|
122
|
+
console.log('No items found');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const specs = readdirSync(SPECS_DIR, { withFileTypes: true })
|
|
126
|
+
.filter(dirent => dirent.isDirectory())
|
|
127
|
+
.map(dirent => {
|
|
128
|
+
const specPath = join(SPECS_DIR, dirent.name, 'spec.md');
|
|
129
|
+
if (existsSync(specPath)) {
|
|
130
|
+
try {
|
|
131
|
+
const spec = parseSpecFromFile(specPath, dirent.name);
|
|
132
|
+
return {
|
|
133
|
+
id: dirent.name,
|
|
134
|
+
title: spec.name,
|
|
135
|
+
requirementCount: spec.requirements.length
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return {
|
|
140
|
+
id: dirent.name,
|
|
141
|
+
title: dirent.name,
|
|
142
|
+
requirementCount: 0
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
})
|
|
148
|
+
.filter((spec) => spec !== null)
|
|
149
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
150
|
+
if (options.json) {
|
|
151
|
+
console.log(JSON.stringify(specs, null, 2));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
if (specs.length === 0) {
|
|
155
|
+
console.log('No items found');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!options.long) {
|
|
159
|
+
specs.forEach(spec => console.log(spec.id));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
specs.forEach(spec => {
|
|
163
|
+
console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
specCommand
|
|
173
|
+
.command('validate [spec-id]')
|
|
174
|
+
.description('Validate a specification structure')
|
|
175
|
+
.option('--strict', 'Enable strict validation mode')
|
|
176
|
+
.option('--json', 'Output validation report as JSON')
|
|
177
|
+
.option('--no-interactive', 'Disable interactive prompts')
|
|
178
|
+
.action(async (specId, options) => {
|
|
179
|
+
try {
|
|
180
|
+
if (!specId) {
|
|
181
|
+
const canPrompt = isInteractive(options?.noInteractive);
|
|
182
|
+
const specIds = await getSpecIds();
|
|
183
|
+
if (canPrompt && specIds.length > 0) {
|
|
184
|
+
specId = await select({
|
|
185
|
+
message: 'Select a spec to validate',
|
|
186
|
+
choices: specIds.map(id => ({ name: id, value: id })),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
throw new Error('Missing required argument <spec-id>');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const specPath = join(SPECS_DIR, specId, 'spec.md');
|
|
194
|
+
if (!existsSync(specPath)) {
|
|
195
|
+
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
|
|
196
|
+
}
|
|
197
|
+
const validator = new Validator(options.strict);
|
|
198
|
+
const report = await validator.validateSpec(specPath);
|
|
199
|
+
if (options.json) {
|
|
200
|
+
console.log(JSON.stringify(report, null, 2));
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
if (report.valid) {
|
|
204
|
+
console.log(`Specification '${specId}' is valid`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
console.error(`Specification '${specId}' has issues`);
|
|
208
|
+
report.issues.forEach(issue => {
|
|
209
|
+
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
|
|
210
|
+
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
|
211
|
+
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
process.exitCode = report.valid ? 0 : 1;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return specCommand;
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=spec.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface ExecuteOptions {
|
|
2
|
+
all?: boolean;
|
|
3
|
+
changes?: boolean;
|
|
4
|
+
specs?: boolean;
|
|
5
|
+
type?: string;
|
|
6
|
+
strict?: boolean;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
noInteractive?: boolean;
|
|
9
|
+
concurrency?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class ValidateCommand {
|
|
12
|
+
execute(itemName: string | undefined, options?: ExecuteOptions): Promise<void>;
|
|
13
|
+
private normalizeType;
|
|
14
|
+
private runInteractiveSelector;
|
|
15
|
+
private printNonInteractiveHint;
|
|
16
|
+
private validateDirectItem;
|
|
17
|
+
private validateByType;
|
|
18
|
+
private printReport;
|
|
19
|
+
private printNextSteps;
|
|
20
|
+
private runBulkValidation;
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Validator } from '../core/validation/validator.js';
|
|
5
|
+
import { isInteractive } from '../utils/interactive.js';
|
|
6
|
+
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
|
|
7
|
+
import { nearestMatches } from '../utils/match.js';
|
|
8
|
+
export class ValidateCommand {
|
|
9
|
+
async execute(itemName, options = {}) {
|
|
10
|
+
const interactive = isInteractive(options.noInteractive);
|
|
11
|
+
// Handle bulk flags first
|
|
12
|
+
if (options.all || options.changes || options.specs) {
|
|
13
|
+
await this.runBulkValidation({
|
|
14
|
+
changes: !!options.all || !!options.changes,
|
|
15
|
+
specs: !!options.all || !!options.specs,
|
|
16
|
+
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// No item and no flags
|
|
20
|
+
if (!itemName) {
|
|
21
|
+
if (interactive) {
|
|
22
|
+
await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.printNonInteractiveHint();
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Direct item validation with type detection or override
|
|
30
|
+
const typeOverride = this.normalizeType(options.type);
|
|
31
|
+
await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json });
|
|
32
|
+
}
|
|
33
|
+
normalizeType(value) {
|
|
34
|
+
if (!value)
|
|
35
|
+
return undefined;
|
|
36
|
+
const v = value.toLowerCase();
|
|
37
|
+
if (v === 'change' || v === 'spec')
|
|
38
|
+
return v;
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
async runInteractiveSelector(opts) {
|
|
42
|
+
const choice = await select({
|
|
43
|
+
message: 'What would you like to validate?',
|
|
44
|
+
choices: [
|
|
45
|
+
{ name: 'All (changes + specs)', value: 'all' },
|
|
46
|
+
{ name: 'All changes', value: 'changes' },
|
|
47
|
+
{ name: 'All specs', value: 'specs' },
|
|
48
|
+
{ name: 'Pick a specific change or spec', value: 'one' },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
if (choice === 'all')
|
|
52
|
+
return this.runBulkValidation({ changes: true, specs: true }, opts);
|
|
53
|
+
if (choice === 'changes')
|
|
54
|
+
return this.runBulkValidation({ changes: true, specs: false }, opts);
|
|
55
|
+
if (choice === 'specs')
|
|
56
|
+
return this.runBulkValidation({ changes: false, specs: true }, opts);
|
|
57
|
+
// one
|
|
58
|
+
const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
|
|
59
|
+
const items = [];
|
|
60
|
+
items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change', id } })));
|
|
61
|
+
items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec', id } })));
|
|
62
|
+
if (items.length === 0) {
|
|
63
|
+
console.error('No items found to validate.');
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const picked = await select({ message: 'Pick an item', choices: items });
|
|
68
|
+
await this.validateByType(picked.type, picked.id, opts);
|
|
69
|
+
}
|
|
70
|
+
printNonInteractiveHint() {
|
|
71
|
+
console.error('Nothing to validate. Try one of:');
|
|
72
|
+
console.error(' openspec validate --all');
|
|
73
|
+
console.error(' openspec validate --changes');
|
|
74
|
+
console.error(' openspec validate --specs');
|
|
75
|
+
console.error(' openspec validate <item-name>');
|
|
76
|
+
console.error('Or run in an interactive terminal.');
|
|
77
|
+
}
|
|
78
|
+
async validateDirectItem(itemName, opts) {
|
|
79
|
+
const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
|
|
80
|
+
const isChange = changes.includes(itemName);
|
|
81
|
+
const isSpec = specs.includes(itemName);
|
|
82
|
+
const type = opts.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);
|
|
83
|
+
if (!type) {
|
|
84
|
+
console.error(`Unknown item '${itemName}'`);
|
|
85
|
+
const suggestions = nearestMatches(itemName, [...changes, ...specs]);
|
|
86
|
+
if (suggestions.length)
|
|
87
|
+
console.error(`Did you mean: ${suggestions.join(', ')}?`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!opts.typeOverride && isChange && isSpec) {
|
|
92
|
+
console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);
|
|
93
|
+
console.error('Pass --type change|spec, or use: openspec change validate / openspec spec validate');
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await this.validateByType(type, itemName, opts);
|
|
98
|
+
}
|
|
99
|
+
async validateByType(type, id, opts) {
|
|
100
|
+
const validator = new Validator(opts.strict);
|
|
101
|
+
if (type === 'change') {
|
|
102
|
+
const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
const report = await validator.validateChangeDeltaSpecs(changeDir);
|
|
105
|
+
const durationMs = Date.now() - start;
|
|
106
|
+
this.printReport('change', id, report, durationMs, opts.json);
|
|
107
|
+
// Non-zero exit if invalid (keeps enriched output test semantics)
|
|
108
|
+
process.exitCode = report.valid ? 0 : 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');
|
|
112
|
+
const start = Date.now();
|
|
113
|
+
const report = await validator.validateSpec(file);
|
|
114
|
+
const durationMs = Date.now() - start;
|
|
115
|
+
this.printReport('spec', id, report, durationMs, opts.json);
|
|
116
|
+
process.exitCode = report.valid ? 0 : 1;
|
|
117
|
+
}
|
|
118
|
+
printReport(type, id, report, durationMs, json) {
|
|
119
|
+
if (json) {
|
|
120
|
+
const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0' };
|
|
121
|
+
console.log(JSON.stringify(out, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (report.valid) {
|
|
125
|
+
console.log(`${type === 'change' ? 'Change' : 'Specification'} '${id}' is valid`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.error(`${type === 'change' ? 'Change' : 'Specification'} '${id}' has issues`);
|
|
129
|
+
for (const issue of report.issues) {
|
|
130
|
+
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
|
|
131
|
+
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
|
132
|
+
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
|
133
|
+
}
|
|
134
|
+
this.printNextSteps(type);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
printNextSteps(type) {
|
|
138
|
+
const bullets = [];
|
|
139
|
+
if (type === 'change') {
|
|
140
|
+
bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
|
|
141
|
+
bullets.push('- Each requirement MUST include at least one #### Scenario: block');
|
|
142
|
+
bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
bullets.push('- Ensure spec includes ## Purpose and ## Requirements sections');
|
|
146
|
+
bullets.push('- Each requirement MUST include at least one #### Scenario: block');
|
|
147
|
+
bullets.push('- Re-run with --json to see structured report');
|
|
148
|
+
}
|
|
149
|
+
console.error('Next steps:');
|
|
150
|
+
bullets.forEach(b => console.error(` ${b}`));
|
|
151
|
+
}
|
|
152
|
+
async runBulkValidation(scope, opts) {
|
|
153
|
+
const spinner = !opts.json ? ora('Validating...').start() : undefined;
|
|
154
|
+
const [changeIds, specIds] = await Promise.all([
|
|
155
|
+
scope.changes ? getActiveChangeIds() : Promise.resolve([]),
|
|
156
|
+
scope.specs ? getSpecIds() : Promise.resolve([]),
|
|
157
|
+
]);
|
|
158
|
+
const DEFAULT_CONCURRENCY = 6;
|
|
159
|
+
const maxSuggestions = 5; // used by nearestMatches
|
|
160
|
+
const concurrency = normalizeConcurrency(opts.concurrency) ?? normalizeConcurrency(process.env.OPENSPEC_CONCURRENCY) ?? DEFAULT_CONCURRENCY;
|
|
161
|
+
const validator = new Validator(opts.strict);
|
|
162
|
+
const queue = [];
|
|
163
|
+
for (const id of changeIds) {
|
|
164
|
+
queue.push(async () => {
|
|
165
|
+
const start = Date.now();
|
|
166
|
+
const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);
|
|
167
|
+
const report = await validator.validateChangeDeltaSpecs(changeDir);
|
|
168
|
+
const durationMs = Date.now() - start;
|
|
169
|
+
return { id, type: 'change', valid: report.valid, issues: report.issues, durationMs };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
for (const id of specIds) {
|
|
173
|
+
queue.push(async () => {
|
|
174
|
+
const start = Date.now();
|
|
175
|
+
const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');
|
|
176
|
+
const report = await validator.validateSpec(file);
|
|
177
|
+
const durationMs = Date.now() - start;
|
|
178
|
+
return { id, type: 'spec', valid: report.valid, issues: report.issues, durationMs };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const results = [];
|
|
182
|
+
let index = 0;
|
|
183
|
+
let running = 0;
|
|
184
|
+
let passed = 0;
|
|
185
|
+
let failed = 0;
|
|
186
|
+
await new Promise((resolve) => {
|
|
187
|
+
const next = () => {
|
|
188
|
+
while (running < concurrency && index < queue.length) {
|
|
189
|
+
const currentIndex = index++;
|
|
190
|
+
const task = queue[currentIndex];
|
|
191
|
+
running++;
|
|
192
|
+
if (spinner)
|
|
193
|
+
spinner.text = `Validating (${currentIndex + 1}/${queue.length})...`;
|
|
194
|
+
task()
|
|
195
|
+
.then(res => {
|
|
196
|
+
results.push(res);
|
|
197
|
+
if (res.valid)
|
|
198
|
+
passed++;
|
|
199
|
+
else
|
|
200
|
+
failed++;
|
|
201
|
+
})
|
|
202
|
+
.catch((error) => {
|
|
203
|
+
const message = error?.message || 'Unknown error';
|
|
204
|
+
const res = { id: getPlannedId(currentIndex, changeIds, specIds) ?? 'unknown', type: getPlannedType(currentIndex, changeIds, specIds) ?? 'change', valid: false, issues: [{ level: 'ERROR', path: 'file', message }], durationMs: 0 };
|
|
205
|
+
results.push(res);
|
|
206
|
+
failed++;
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
running--;
|
|
210
|
+
if (index >= queue.length && running === 0)
|
|
211
|
+
resolve();
|
|
212
|
+
else
|
|
213
|
+
next();
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
next();
|
|
218
|
+
});
|
|
219
|
+
spinner?.stop();
|
|
220
|
+
results.sort((a, b) => a.id.localeCompare(b.id));
|
|
221
|
+
const summary = {
|
|
222
|
+
totals: { items: results.length, passed, failed },
|
|
223
|
+
byType: {
|
|
224
|
+
...(scope.changes ? { change: summarizeType(results, 'change') } : {}),
|
|
225
|
+
...(scope.specs ? { spec: summarizeType(results, 'spec') } : {}),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
if (opts.json) {
|
|
229
|
+
const out = { items: results, summary, version: '1.0' };
|
|
230
|
+
console.log(JSON.stringify(out, null, 2));
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
for (const res of results) {
|
|
234
|
+
if (res.valid)
|
|
235
|
+
console.log(`✓ ${res.type}/${res.id}`);
|
|
236
|
+
else
|
|
237
|
+
console.error(`✗ ${res.type}/${res.id}`);
|
|
238
|
+
}
|
|
239
|
+
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
|
|
240
|
+
}
|
|
241
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function summarizeType(results, type) {
|
|
245
|
+
const filtered = results.filter(r => r.type === type);
|
|
246
|
+
const items = filtered.length;
|
|
247
|
+
const passed = filtered.filter(r => r.valid).length;
|
|
248
|
+
const failed = items - passed;
|
|
249
|
+
return { items, passed, failed };
|
|
250
|
+
}
|
|
251
|
+
function normalizeConcurrency(value) {
|
|
252
|
+
if (!value)
|
|
253
|
+
return undefined;
|
|
254
|
+
const n = parseInt(value, 10);
|
|
255
|
+
if (Number.isNaN(n) || n <= 0)
|
|
256
|
+
return undefined;
|
|
257
|
+
return n;
|
|
258
|
+
}
|
|
259
|
+
function getPlannedId(index, changeIds, specIds) {
|
|
260
|
+
const totalChanges = changeIds.length;
|
|
261
|
+
if (index < totalChanges)
|
|
262
|
+
return changeIds[index];
|
|
263
|
+
const specIndex = index - totalChanges;
|
|
264
|
+
return specIds[specIndex];
|
|
265
|
+
}
|
|
266
|
+
function getPlannedType(index, changeIds, specIds) {
|
|
267
|
+
const totalChanges = changeIds.length;
|
|
268
|
+
if (index < totalChanges)
|
|
269
|
+
return 'change';
|
|
270
|
+
const specIndex = index - totalChanges;
|
|
271
|
+
if (specIndex >= 0 && specIndex < specIds.length)
|
|
272
|
+
return 'spec';
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=validate.js.map
|