@fission-ai/openspec 1.10.0 → 1.12.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 +1 -0
- package/dist/cli/index.js +12 -2
- package/dist/commands/change.d.ts +24 -0
- package/dist/commands/change.js +229 -5
- package/dist/commands/schema.d.ts +5 -0
- package/dist/commands/schema.js +201 -35
- package/dist/commands/show.js +1 -1
- package/dist/commands/validate.d.ts +57 -0
- package/dist/commands/validate.js +99 -13
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/status.d.ts +2 -0
- package/dist/commands/workflow/status.js +76 -12
- package/dist/core/artifact-graph/resolver.js +15 -9
- package/dist/core/available-tools.js +16 -1
- package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -2
- package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
- package/dist/core/command-generation/adapters/codeassistant.js +29 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completions/command-registry.js +21 -0
- package/dist/core/completions/generators/fish-generator.d.ts +12 -0
- package/dist/core/completions/generators/fish-generator.js +79 -47
- package/dist/core/completions/templates/fish-templates.d.ts +1 -1
- package/dist/core/completions/templates/fish-templates.js +107 -10
- package/dist/core/completions/types.d.ts +4 -0
- package/dist/core/config.js +7 -1
- package/dist/core/init.d.ts +1 -0
- package/dist/core/init.js +71 -65
- package/dist/core/legacy-cleanup.js +6 -0
- package/dist/core/migration.js +42 -12
- package/dist/core/openspec-root.d.ts +1 -0
- package/dist/core/openspec-root.js +10 -2
- package/dist/core/parsers/requirement-blocks.d.ts +1 -0
- package/dist/core/parsers/requirement-blocks.js +2 -0
- package/dist/core/shared/ide-restart.d.ts +26 -0
- package/dist/core/shared/ide-restart.js +42 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/shared-skill-target.d.ts +13 -0
- package/dist/core/shared-skill-target.js +43 -1
- package/dist/core/specs-apply.js +22 -9
- package/dist/core/templates/workflows/explore.js +108 -73
- package/dist/core/templates/workflows/ff-change.js +8 -0
- package/dist/core/templates/workflows/propose.js +8 -0
- package/dist/core/update.js +28 -31
- package/dist/core/validation/constants.d.ts +3 -0
- package/dist/core/validation/constants.js +12 -0
- package/dist/core/validation/purpose-placeholder.d.ts +45 -0
- package/dist/core/validation/purpose-placeholder.js +117 -0
- package/dist/core/validation/validator.d.ts +12 -3
- package/dist/core/validation/validator.js +90 -4
- package/dist/utils/command-references.js +7 -8
- package/dist/utils/requirement-diff.d.ts +42 -0
- package/dist/utils/requirement-diff.js +61 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -172,6 +172,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
|
|
|
172
172
|
→ **[Concepts](docs/concepts.md)**: how it all fits<br>
|
|
173
173
|
→ **[Multi-Language](docs/multi-language.md)**: multi-language support<br>
|
|
174
174
|
→ **[Customization](docs/customization.md)**: make it yours<br>
|
|
175
|
+
→ **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec<br>
|
|
175
176
|
→ **[FAQ](docs/faq.md)** · **[Troubleshooting](docs/troubleshooting.md)** · **[Glossary](docs/glossary.md)**: quick help
|
|
176
177
|
|
|
177
178
|
|
package/dist/cli/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { registerStoreCommand } from '../commands/store.js';
|
|
|
24
24
|
import { registerDoctorCommand } from '../commands/doctor.js';
|
|
25
25
|
import { registerContextCommand } from '../commands/context.js';
|
|
26
26
|
import { registerWorksetCommand } from '../commands/workset.js';
|
|
27
|
-
import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
|
|
27
|
+
import { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
|
|
28
28
|
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
|
29
29
|
import { maybeShowCompletionTip } from '../core/completion-tip.js';
|
|
30
30
|
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
|
@@ -366,6 +366,7 @@ changeCmd
|
|
|
366
366
|
.option('--json', 'Output as JSON')
|
|
367
367
|
.option('--deltas-only', 'Show only deltas (JSON only)')
|
|
368
368
|
.option('--requirements-only', 'Alias for --deltas-only (deprecated)')
|
|
369
|
+
.option('--diff', 'Show per-requirement diffs for delta specs')
|
|
369
370
|
.option('--no-interactive', 'Disable interactive prompts')
|
|
370
371
|
.action(async (changeName, options) => {
|
|
371
372
|
try {
|
|
@@ -448,6 +449,7 @@ program
|
|
|
448
449
|
.option('--changes', 'Validate all changes')
|
|
449
450
|
.option('--specs', 'Validate all specs')
|
|
450
451
|
.option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
|
|
452
|
+
.option('--report <full|findings>', 'Select bulk report content: full|findings; combine with --json for JSON')
|
|
451
453
|
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
|
|
452
454
|
.option('--strict', 'Enable strict validation mode')
|
|
453
455
|
.option('--json', 'Output validation results as JSON')
|
|
@@ -475,6 +477,7 @@ program
|
|
|
475
477
|
// change-only flags
|
|
476
478
|
.option('--deltas-only', 'Show only deltas (JSON only, change)')
|
|
477
479
|
.option('--requirements-only', 'Alias for --deltas-only (deprecated, change)')
|
|
480
|
+
.option('--diff', 'Show per-requirement diffs for delta specs (change)')
|
|
478
481
|
// spec-only flags
|
|
479
482
|
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
|
|
480
483
|
.option('--no-scenarios', 'JSON only: Exclude scenario content')
|
|
@@ -577,6 +580,7 @@ program
|
|
|
577
580
|
.command('status')
|
|
578
581
|
.description('Display artifact completion status for a change')
|
|
579
582
|
.option('--change <id>', 'Change name to show status for')
|
|
583
|
+
.option('--all', 'Show status for all active changes')
|
|
580
584
|
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
|
|
581
585
|
.option('--json', 'Output as JSON')
|
|
582
586
|
.option('--store <id>', STORE_OPTION_DESCRIPTION)
|
|
@@ -586,7 +590,13 @@ program
|
|
|
586
590
|
await statusCommand(options);
|
|
587
591
|
}
|
|
588
592
|
catch (error) {
|
|
589
|
-
failWithError(error, {
|
|
593
|
+
failWithError(error, {
|
|
594
|
+
enabled: options.json,
|
|
595
|
+
// The batch null-shape; the single-change failure shape is
|
|
596
|
+
// pre-existing contract and stays payload-free.
|
|
597
|
+
payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
|
|
598
|
+
fallbackCode: 'change_error',
|
|
599
|
+
});
|
|
590
600
|
process.exit(1);
|
|
591
601
|
}
|
|
592
602
|
});
|
|
@@ -4,19 +4,43 @@ export declare class ChangeCommand {
|
|
|
4
4
|
private rootPath?;
|
|
5
5
|
constructor(rootPath?: string);
|
|
6
6
|
private getChangesPath;
|
|
7
|
+
private getSpecsPath;
|
|
7
8
|
/**
|
|
8
9
|
* Show a change proposal.
|
|
9
10
|
* - Text mode: raw markdown passthrough (no filters)
|
|
10
11
|
* - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
|
|
11
12
|
* Note: --requirements-only is deprecated alias for --deltas-only
|
|
13
|
+
* - --diff: per-requirement diffs of the delta specs against the main specs,
|
|
14
|
+
* appended in text mode and attached to MODIFIED deltas in JSON mode
|
|
12
15
|
*/
|
|
13
16
|
show(changeName?: string, options?: {
|
|
14
17
|
json?: boolean;
|
|
15
18
|
requirementsOnly?: boolean;
|
|
16
19
|
deltasOnly?: boolean;
|
|
20
|
+
diff?: boolean;
|
|
17
21
|
noInteractive?: boolean;
|
|
18
22
|
rootOutput?: RootOutput;
|
|
19
23
|
}): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Read every delta spec under the change and pair each requirement with its
|
|
26
|
+
* counterpart in the main spec. Text mode and JSON mode both render from this
|
|
27
|
+
* one pass, so the two surfaces cannot drift apart.
|
|
28
|
+
*/
|
|
29
|
+
private collectSpecDiffs;
|
|
30
|
+
/**
|
|
31
|
+
* Attach `diff` (or `warning`) to every MODIFIED delta in the JSON payload.
|
|
32
|
+
* Mutates the deltas array in place.
|
|
33
|
+
*
|
|
34
|
+
* The parsed Delta objects carry the requirement body in `description`, not
|
|
35
|
+
* the header name, so they are matched to parsed blocks by capability and
|
|
36
|
+
* source order: ChangeParser emits one Delta per MODIFIED block in that order.
|
|
37
|
+
*/
|
|
38
|
+
private enrichDeltasWithDiffs;
|
|
39
|
+
/**
|
|
40
|
+
* Text mode: per-requirement diffs of the delta specs against the main specs.
|
|
41
|
+
*/
|
|
42
|
+
private showSpecDiffs;
|
|
43
|
+
private printDiffText;
|
|
20
44
|
/**
|
|
21
45
|
* List active changes.
|
|
22
46
|
* - Text default: IDs only; --long prints minimal details (title, counts)
|
package/dist/commands/change.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
3
4
|
import { JsonConverter } from '../core/converters/json-converter.js';
|
|
4
5
|
import { Validator } from '../core/validation/validator.js';
|
|
5
6
|
import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
|
|
@@ -8,6 +9,9 @@ import { isInteractive } from '../utils/interactive.js';
|
|
|
8
9
|
import { getActiveChangeIds } from '../utils/item-discovery.js';
|
|
9
10
|
import { getTaskProgressForChange } from '../utils/task-progress.js';
|
|
10
11
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
12
|
+
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
13
|
+
import { foldRequirementName, parseDeltaSpec, } from '../core/parsers/requirement-blocks.js';
|
|
14
|
+
import { extractRequirementBlock, diffRequirementBlock, buildRenameMap, } from '../utils/requirement-diff.js';
|
|
11
15
|
/**
|
|
12
16
|
* True only when `target` is definitively absent. An EACCES or I/O failure
|
|
13
17
|
* means existence cannot be determined, so callers fall through to their
|
|
@@ -39,11 +43,18 @@ export class ChangeCommand {
|
|
|
39
43
|
getChangesPath() {
|
|
40
44
|
return path.join(this.rootPath ?? process.cwd(), 'openspec', 'changes');
|
|
41
45
|
}
|
|
46
|
+
// Main specs resolve against the same root as changes, so `--diff` reads the
|
|
47
|
+
// selected store's specs rather than whatever sits under the cwd.
|
|
48
|
+
getSpecsPath() {
|
|
49
|
+
return path.join(this.rootPath ?? process.cwd(), 'openspec', 'specs');
|
|
50
|
+
}
|
|
42
51
|
/**
|
|
43
52
|
* Show a change proposal.
|
|
44
53
|
* - Text mode: raw markdown passthrough (no filters)
|
|
45
54
|
* - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
|
|
46
55
|
* Note: --requirements-only is deprecated alias for --deltas-only
|
|
56
|
+
* - --diff: per-requirement diffs of the delta specs against the main specs,
|
|
57
|
+
* appended in text mode and attached to MODIFIED deltas in JSON mode
|
|
47
58
|
*/
|
|
48
59
|
async show(changeName, options) {
|
|
49
60
|
const changesPath = this.getChangesPath();
|
|
@@ -109,6 +120,9 @@ export class ChangeCommand {
|
|
|
109
120
|
const title = this.extractTitle(contentForTitle, changeName);
|
|
110
121
|
const id = parsed.name;
|
|
111
122
|
const deltas = parsed.deltas || [];
|
|
123
|
+
if (options.diff) {
|
|
124
|
+
await this.enrichDeltasWithDiffs(deltas, changeName, changesPath);
|
|
125
|
+
}
|
|
112
126
|
const output = {
|
|
113
127
|
id,
|
|
114
128
|
title,
|
|
@@ -122,6 +136,215 @@ export class ChangeCommand {
|
|
|
122
136
|
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
|
|
123
137
|
const content = await fs.readFile(proposalPath, 'utf-8');
|
|
124
138
|
console.log(content);
|
|
139
|
+
if (options?.diff) {
|
|
140
|
+
await this.showSpecDiffs(changeName, changesPath);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Read every delta spec under the change and pair each requirement with its
|
|
146
|
+
* counterpart in the main spec. Text mode and JSON mode both render from this
|
|
147
|
+
* one pass, so the two surfaces cannot drift apart.
|
|
148
|
+
*/
|
|
149
|
+
async collectSpecDiffs(changeName, changesPath) {
|
|
150
|
+
const specsDir = path.join(changesPath, changeName, 'specs');
|
|
151
|
+
const mainSpecsDir = this.getSpecsPath();
|
|
152
|
+
// Same discovery ChangeParser uses, so a nested capability (specs/<area>/<id>)
|
|
153
|
+
// is diffed rather than silently skipped, and the ids here match the `spec`
|
|
154
|
+
// field of the JSON deltas.
|
|
155
|
+
const discovered = await discoverSpecFiles(specsDir);
|
|
156
|
+
const capabilities = discovered.map(spec => spec.id);
|
|
157
|
+
const results = [];
|
|
158
|
+
for (const { id: capability, specFile: deltaSpecPath } of discovered) {
|
|
159
|
+
const deltaContent = await fs.readFile(deltaSpecPath, 'utf-8');
|
|
160
|
+
const mainSpecPath = path.join(mainSpecsDir, ...capability.split('/'), 'spec.md');
|
|
161
|
+
let mainContent = null;
|
|
162
|
+
try {
|
|
163
|
+
FileSystemUtils.assertPathWithin(mainSpecsDir, mainSpecPath);
|
|
164
|
+
mainContent = await fs.readFile(mainSpecPath, 'utf-8');
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (error?.code !== 'ENOENT')
|
|
168
|
+
throw error;
|
|
169
|
+
// No main spec on disk. For ADDED requirements that is the ordinary new
|
|
170
|
+
// capability case; MODIFIED requirements are handled as a mismatch below.
|
|
171
|
+
}
|
|
172
|
+
const plan = parseDeltaSpec(deltaContent);
|
|
173
|
+
const renameMap = buildRenameMap(plan.renamed);
|
|
174
|
+
for (const block of plan.added) {
|
|
175
|
+
results.push({ capability, operation: 'ADDED', requirementName: block.name, raw: block.raw });
|
|
176
|
+
}
|
|
177
|
+
// Prefer the authored REMOVED block so its Reason/Migration text reaches
|
|
178
|
+
// the reader; the bullet-list form carries a name and nothing else.
|
|
179
|
+
const removedBlocks = new Map(plan.removedBlocks.map(block => [foldRequirementName(block.name), block.raw]));
|
|
180
|
+
for (const name of plan.removed) {
|
|
181
|
+
const raw = removedBlocks.get(foldRequirementName(name));
|
|
182
|
+
results.push({
|
|
183
|
+
capability,
|
|
184
|
+
operation: 'REMOVED',
|
|
185
|
+
requirementName: name,
|
|
186
|
+
raw: raw ?? `### Requirement: ${name}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
for (const rename of plan.renamed) {
|
|
190
|
+
results.push({ capability, operation: 'RENAMED', requirementName: rename.to, raw: '', rename });
|
|
191
|
+
}
|
|
192
|
+
for (const block of plan.modified) {
|
|
193
|
+
const entry = {
|
|
194
|
+
capability,
|
|
195
|
+
operation: 'MODIFIED',
|
|
196
|
+
requirementName: block.name,
|
|
197
|
+
raw: block.raw,
|
|
198
|
+
};
|
|
199
|
+
// A requirement renamed and modified in the same delta still lives in
|
|
200
|
+
// the main spec under its old name, so look it up there.
|
|
201
|
+
const oldName = renameMap.get(foldRequirementName(block.name));
|
|
202
|
+
const lookupName = oldName ?? block.name;
|
|
203
|
+
const match = mainContent ? extractRequirementBlock(mainContent, lookupName) : null;
|
|
204
|
+
if (match) {
|
|
205
|
+
entry.diff = diffRequirementBlock(match.raw, block.raw, `${capability}/${block.name}`);
|
|
206
|
+
if (!match.exact) {
|
|
207
|
+
// Archive matches requirement names exactly, so a header that
|
|
208
|
+
// differs only in case or spacing will not merge. Show the diff the
|
|
209
|
+
// author meant, and name the mismatch while it is still cheap to fix.
|
|
210
|
+
entry.warning =
|
|
211
|
+
`Header differs from the main spec's "${match.name}" only in case or spacing; ` +
|
|
212
|
+
`archive matches names exactly, so reconcile them before archiving`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else if (mainContent) {
|
|
216
|
+
entry.warning = `No matching main requirement found for "${lookupName}" in ${capability}`;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// A MODIFIED requirement names a block that should already exist, so a
|
|
220
|
+
// missing main spec is an authoring error, not a new capability.
|
|
221
|
+
// Rendering it as all-additions would hide what archive will reject.
|
|
222
|
+
entry.warning =
|
|
223
|
+
`No main spec at openspec/specs/${capability}/spec.md, ` +
|
|
224
|
+
`so MODIFIED requirement "${block.name}" has nothing to diff against`;
|
|
225
|
+
}
|
|
226
|
+
results.push(entry);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { capabilities, results };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Attach `diff` (or `warning`) to every MODIFIED delta in the JSON payload.
|
|
233
|
+
* Mutates the deltas array in place.
|
|
234
|
+
*
|
|
235
|
+
* The parsed Delta objects carry the requirement body in `description`, not
|
|
236
|
+
* the header name, so they are matched to parsed blocks by capability and
|
|
237
|
+
* source order: ChangeParser emits one Delta per MODIFIED block in that order.
|
|
238
|
+
*/
|
|
239
|
+
async enrichDeltasWithDiffs(deltas, changeName, changesPath) {
|
|
240
|
+
const modifiedDeltasBySpec = new Map();
|
|
241
|
+
for (const delta of deltas) {
|
|
242
|
+
if (!delta.spec || delta.operation !== 'MODIFIED')
|
|
243
|
+
continue;
|
|
244
|
+
const list = modifiedDeltasBySpec.get(delta.spec) ?? [];
|
|
245
|
+
list.push(delta);
|
|
246
|
+
modifiedDeltasBySpec.set(delta.spec, list);
|
|
247
|
+
}
|
|
248
|
+
if (modifiedDeltasBySpec.size === 0)
|
|
249
|
+
return;
|
|
250
|
+
const { results } = await this.collectSpecDiffs(changeName, changesPath);
|
|
251
|
+
const modifiedEntriesBySpec = new Map();
|
|
252
|
+
for (const entry of results) {
|
|
253
|
+
if (entry.operation !== 'MODIFIED')
|
|
254
|
+
continue;
|
|
255
|
+
const list = modifiedEntriesBySpec.get(entry.capability) ?? [];
|
|
256
|
+
list.push(entry);
|
|
257
|
+
modifiedEntriesBySpec.set(entry.capability, list);
|
|
258
|
+
}
|
|
259
|
+
for (const [capability, modifiedDeltas] of modifiedDeltasBySpec) {
|
|
260
|
+
const entries = modifiedEntriesBySpec.get(capability) ?? [];
|
|
261
|
+
for (let i = 0; i < modifiedDeltas.length && i < entries.length; i++) {
|
|
262
|
+
const entry = entries[i];
|
|
263
|
+
if (entry.diff !== undefined) {
|
|
264
|
+
modifiedDeltas[i].diff = entry.diff;
|
|
265
|
+
}
|
|
266
|
+
if (entry.warning !== undefined) {
|
|
267
|
+
modifiedDeltas[i].warning = entry.warning;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Text mode: per-requirement diffs of the delta specs against the main specs.
|
|
274
|
+
*/
|
|
275
|
+
async showSpecDiffs(changeName, changesPath) {
|
|
276
|
+
const { capabilities, results } = await this.collectSpecDiffs(changeName, changesPath);
|
|
277
|
+
console.log();
|
|
278
|
+
if (capabilities.length === 0 || results.length === 0) {
|
|
279
|
+
// Not an error: a change can be proposal-only. Saying so beats printing a
|
|
280
|
+
// heading with nothing under it.
|
|
281
|
+
console.log(`No delta specs to diff for change "${changeName}".`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
console.log(chalk.bold('Specifications Changed (diffs)'));
|
|
285
|
+
console.log();
|
|
286
|
+
this.printDiffText(results);
|
|
287
|
+
}
|
|
288
|
+
printDiffText(results) {
|
|
289
|
+
let currentCap = '';
|
|
290
|
+
for (const r of results) {
|
|
291
|
+
if (r.capability !== currentCap) {
|
|
292
|
+
if (currentCap)
|
|
293
|
+
console.log();
|
|
294
|
+
currentCap = r.capability;
|
|
295
|
+
console.log(chalk.bold.underline(currentCap));
|
|
296
|
+
console.log();
|
|
297
|
+
}
|
|
298
|
+
switch (r.operation) {
|
|
299
|
+
case 'ADDED':
|
|
300
|
+
console.log(chalk.green.bold(` ADDED: ${r.requirementName}`));
|
|
301
|
+
for (const line of r.raw.split('\n')) {
|
|
302
|
+
console.log(chalk.green(` ${line}`));
|
|
303
|
+
}
|
|
304
|
+
console.log();
|
|
305
|
+
break;
|
|
306
|
+
case 'REMOVED':
|
|
307
|
+
console.log(chalk.red.bold(` REMOVED: ${r.requirementName}`));
|
|
308
|
+
for (const line of r.raw.split('\n')) {
|
|
309
|
+
console.log(chalk.red(` ${line}`));
|
|
310
|
+
}
|
|
311
|
+
console.log();
|
|
312
|
+
break;
|
|
313
|
+
case 'RENAMED':
|
|
314
|
+
console.log(chalk.cyan.bold(` RENAMED: ${r.rename?.from} → ${r.rename?.to}`));
|
|
315
|
+
console.log();
|
|
316
|
+
break;
|
|
317
|
+
case 'MODIFIED':
|
|
318
|
+
console.log(chalk.yellow.bold(` MODIFIED: ${r.requirementName}`));
|
|
319
|
+
if (r.warning) {
|
|
320
|
+
console.log(chalk.yellow(` ⚠ ${r.warning}`));
|
|
321
|
+
}
|
|
322
|
+
// A near-miss header carries both: the warning about the mismatch and
|
|
323
|
+
// the diff against the block it almost matched.
|
|
324
|
+
if (r.diff === undefined) {
|
|
325
|
+
for (const line of r.raw.split('\n')) {
|
|
326
|
+
console.log(` ${line}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else if (r.diff === '') {
|
|
330
|
+
console.log(chalk.dim(' (no textual changes)'));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
for (const line of r.diff.split('\n')) {
|
|
334
|
+
if (line.startsWith('+')) {
|
|
335
|
+
console.log(chalk.green(` ${line}`));
|
|
336
|
+
}
|
|
337
|
+
else if (line.startsWith('-')) {
|
|
338
|
+
console.log(chalk.red(` ${line}`));
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
console.log(` ${line}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
console.log();
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
125
348
|
}
|
|
126
349
|
}
|
|
127
350
|
/**
|
|
@@ -257,11 +480,12 @@ export class ChangeCommand {
|
|
|
257
480
|
}
|
|
258
481
|
else {
|
|
259
482
|
console.error(`Change "${changeName}" has issues`);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
483
|
+
}
|
|
484
|
+
report.issues.forEach(issue => {
|
|
485
|
+
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
|
486
|
+
console.error(`${prefix} [${issue.level}] ${issue.path}: ${issue.message}`);
|
|
487
|
+
});
|
|
488
|
+
if (!report.valid) {
|
|
265
489
|
// Next steps footer to guide fixing issues
|
|
266
490
|
this.printNextSteps(report.issues);
|
|
267
491
|
if (!options?.json) {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
/** @internal File-operation seam for transactional failure tests. */
|
|
4
|
+
export declare const schemaInitFileOperations: {
|
|
5
|
+
renameSync: typeof fs.renameSync;
|
|
6
|
+
};
|
|
2
7
|
/**
|
|
3
8
|
* Register the schema command and all its subcommands.
|
|
4
9
|
*/
|
package/dist/commands/schema.js
CHANGED
|
@@ -2,9 +2,10 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { stringify as stringifyYaml, parseDocument } from 'yaml';
|
|
5
|
+
import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml';
|
|
6
6
|
import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
|
|
7
7
|
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
|
|
8
|
+
import { resolveConfigFilePath } from '../core/project-config.js';
|
|
8
9
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
9
10
|
/**
|
|
10
11
|
* Check all three locations for a schema and return which ones exist.
|
|
@@ -289,6 +290,65 @@ function fingerprintDir(dir) {
|
|
|
289
290
|
walk(dir, '');
|
|
290
291
|
return hash.digest('hex');
|
|
291
292
|
}
|
|
293
|
+
/** @internal File-operation seam for transactional failure tests. */
|
|
294
|
+
export const schemaInitFileOperations = {
|
|
295
|
+
renameSync: fs.renameSync,
|
|
296
|
+
};
|
|
297
|
+
async function prepareDefaultConfigUpdate(projectRoot, schemaName) {
|
|
298
|
+
const configPath = resolveConfigFilePath(projectRoot) ??
|
|
299
|
+
path.join(projectRoot, 'openspec', 'config.yaml');
|
|
300
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, configPath);
|
|
301
|
+
if (fs.existsSync(configPath)) {
|
|
302
|
+
const stats = fs.lstatSync(configPath);
|
|
303
|
+
if (stats.isSymbolicLink()) {
|
|
304
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must be a regular file, not a symbolic link`);
|
|
305
|
+
}
|
|
306
|
+
if (!stats.isFile()) {
|
|
307
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must be a regular file`);
|
|
308
|
+
}
|
|
309
|
+
if (!(await FileSystemUtils.canWriteFile(configPath)) ||
|
|
310
|
+
!(await FileSystemUtils.canWriteFile(path.dirname(configPath)))) {
|
|
311
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} is not writable`);
|
|
312
|
+
}
|
|
313
|
+
const originalContent = fs.readFileSync(configPath);
|
|
314
|
+
const config = parseDocument(originalContent.toString('utf-8'));
|
|
315
|
+
if (config.errors.length > 0) {
|
|
316
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} is invalid YAML`);
|
|
317
|
+
}
|
|
318
|
+
if (config.contents !== null && !isMap(config.contents)) {
|
|
319
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must contain a YAML object`);
|
|
320
|
+
}
|
|
321
|
+
config.set('schema', schemaName);
|
|
322
|
+
config.delete('defaultSchema');
|
|
323
|
+
return {
|
|
324
|
+
path: configPath,
|
|
325
|
+
content: Buffer.from(config.toString()),
|
|
326
|
+
originalContent,
|
|
327
|
+
originalMode: stats.mode,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (!(await FileSystemUtils.canWriteFile(configPath))) {
|
|
331
|
+
throw new Error(`Cannot set the default schema: ${path.dirname(configPath)} is not writable`);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
path: configPath,
|
|
335
|
+
content: Buffer.from(stringifyYaml({ schema: schemaName })),
|
|
336
|
+
originalContent: null,
|
|
337
|
+
originalMode: null,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function configMatchesPreparedState(prepared) {
|
|
341
|
+
if (prepared.originalContent === null) {
|
|
342
|
+
return !fs.existsSync(prepared.path);
|
|
343
|
+
}
|
|
344
|
+
if (!fs.existsSync(prepared.path))
|
|
345
|
+
return false;
|
|
346
|
+
const stats = fs.lstatSync(prepared.path);
|
|
347
|
+
return (stats.isFile() &&
|
|
348
|
+
!stats.isSymbolicLink() &&
|
|
349
|
+
stats.mode === prepared.originalMode &&
|
|
350
|
+
fs.readFileSync(prepared.path).equals(prepared.originalContent));
|
|
351
|
+
}
|
|
292
352
|
/**
|
|
293
353
|
* Default artifacts with descriptions for schema init.
|
|
294
354
|
*/
|
|
@@ -952,46 +1012,152 @@ export function registerSchemaCommand(program) {
|
|
|
952
1012
|
tracks: 'tasks.md',
|
|
953
1013
|
};
|
|
954
1014
|
}
|
|
955
|
-
//
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1015
|
+
// Parse and serialize the config before staging any schema files. This
|
|
1016
|
+
// makes malformed, non-object, linked, and read-only configs fail before
|
|
1017
|
+
// an existing schema can be moved or a new one can appear.
|
|
1018
|
+
const preparedConfig = options?.default
|
|
1019
|
+
? await prepareDefaultConfigUpdate(projectRoot, name)
|
|
1020
|
+
: null;
|
|
1021
|
+
const schemasDir = getProjectSchemasDir(projectRoot);
|
|
1022
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
|
|
1023
|
+
const authorizedSchemaFingerprint = schemaExists
|
|
1024
|
+
? fingerprintDir(schemaDir)
|
|
1025
|
+
: null;
|
|
962
1026
|
if (spinner)
|
|
963
1027
|
spinner.start(`Creating schema '${name}'...`);
|
|
964
|
-
fs.mkdirSync(
|
|
965
|
-
fs.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
1028
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
1029
|
+
const schemaStagingDir = fs.mkdtempSync(path.join(schemasDir, '.init-staging-'));
|
|
1030
|
+
let configStagingDir = null;
|
|
1031
|
+
let stagedConfigPath = null;
|
|
1032
|
+
try {
|
|
1033
|
+
fs.writeFileSync(path.join(schemaStagingDir, 'schema.yaml'), stringifyYaml(schema));
|
|
1034
|
+
const templatesDir = path.join(schemaStagingDir, 'templates');
|
|
1035
|
+
for (const artifact of selectedArtifacts) {
|
|
1036
|
+
const templatePath = path.join(templatesDir, artifact.template);
|
|
1037
|
+
fs.mkdirSync(path.dirname(templatePath), { recursive: true });
|
|
1038
|
+
fs.writeFileSync(templatePath, createDefaultTemplate(artifact.id));
|
|
1039
|
+
}
|
|
1040
|
+
const validation = validateSchema(schemaStagingDir);
|
|
1041
|
+
if (!validation.valid) {
|
|
1042
|
+
throw new Error(`Generated schema failed validation: ${validation.issues
|
|
1043
|
+
.map((issue) => issue.message)
|
|
1044
|
+
.join('; ')}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (preparedConfig) {
|
|
1047
|
+
const configDir = path.dirname(preparedConfig.path);
|
|
1048
|
+
configStagingDir = fs.mkdtempSync(path.join(configDir, '.schema-init-config-'));
|
|
1049
|
+
stagedConfigPath = path.join(configStagingDir, path.basename(preparedConfig.path));
|
|
1050
|
+
fs.writeFileSync(stagedConfigPath, preparedConfig.content);
|
|
1051
|
+
if (preparedConfig.originalMode !== null) {
|
|
1052
|
+
fs.chmodSync(stagedConfigPath, preparedConfig.originalMode);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
// Re-resolve both destinations immediately before the first move so
|
|
1056
|
+
// a parent symlink swap during staging cannot redirect the commit.
|
|
1057
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
|
|
1058
|
+
if (preparedConfig) {
|
|
1059
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, preparedConfig.path);
|
|
1060
|
+
}
|
|
1061
|
+
const currentSchemaFingerprint = fs.existsSync(schemaDir)
|
|
1062
|
+
? fingerprintDir(schemaDir)
|
|
1063
|
+
: null;
|
|
1064
|
+
if (currentSchemaFingerprint !== authorizedSchemaFingerprint) {
|
|
1065
|
+
throw new Error(`Schema '${name}' changed on disk while initialization was being prepared. ` +
|
|
1066
|
+
'Aborted to preserve those concurrent changes.');
|
|
1067
|
+
}
|
|
1068
|
+
if (preparedConfig && !configMatchesPreparedState(preparedConfig)) {
|
|
1069
|
+
throw new Error(`${path.basename(preparedConfig.path)} changed on disk while initialization was being prepared. ` +
|
|
1070
|
+
'Aborted to preserve those concurrent changes.');
|
|
1071
|
+
}
|
|
1072
|
+
const token = `${process.pid}-${Date.now()}`;
|
|
1073
|
+
const schemaBackup = `${schemaDir}.init-backup-${token}`;
|
|
1074
|
+
const configBackup = preparedConfig
|
|
1075
|
+
? `${preparedConfig.path}.init-backup-${token}`
|
|
1076
|
+
: null;
|
|
1077
|
+
let schemaBackedUp = false;
|
|
1078
|
+
let configBackedUp = false;
|
|
1079
|
+
let schemaInstalled = false;
|
|
1080
|
+
let configInstalled = false;
|
|
1081
|
+
try {
|
|
1082
|
+
if (schemaExists) {
|
|
1083
|
+
schemaInitFileOperations.renameSync(schemaDir, schemaBackup);
|
|
1084
|
+
schemaBackedUp = true;
|
|
1085
|
+
}
|
|
1086
|
+
if (preparedConfig && preparedConfig.originalContent !== null) {
|
|
1087
|
+
schemaInitFileOperations.renameSync(preparedConfig.path, configBackup);
|
|
1088
|
+
configBackedUp = true;
|
|
1089
|
+
}
|
|
1090
|
+
schemaInitFileOperations.renameSync(schemaStagingDir, schemaDir);
|
|
1091
|
+
schemaInstalled = true;
|
|
1092
|
+
if (preparedConfig && stagedConfigPath) {
|
|
1093
|
+
schemaInitFileOperations.renameSync(stagedConfigPath, preparedConfig.path);
|
|
1094
|
+
configInstalled = true;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
catch (installError) {
|
|
1098
|
+
const rollbackErrors = [];
|
|
1099
|
+
try {
|
|
1100
|
+
if (configInstalled && preparedConfig) {
|
|
1101
|
+
fs.rmSync(preparedConfig.path, { force: true });
|
|
1102
|
+
}
|
|
1103
|
+
if (configBackedUp && preparedConfig && configBackup) {
|
|
1104
|
+
schemaInitFileOperations.renameSync(configBackup, preparedConfig.path);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
catch (rollbackError) {
|
|
1108
|
+
rollbackErrors.push(`config: ${rollbackError.message}`);
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
if (schemaInstalled) {
|
|
1112
|
+
fs.rmSync(schemaDir, { recursive: true, force: true });
|
|
1113
|
+
}
|
|
1114
|
+
if (schemaBackedUp) {
|
|
1115
|
+
schemaInitFileOperations.renameSync(schemaBackup, schemaDir);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
catch (rollbackError) {
|
|
1119
|
+
rollbackErrors.push(`schema: ${rollbackError.message}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (rollbackErrors.length > 0) {
|
|
1122
|
+
throw new Error(`Schema initialization failed and rollback was incomplete (${rollbackErrors.join(', ')}). ` +
|
|
1123
|
+
`Recovery backups may remain beside ${schemaDir} and ${preparedConfig?.path ?? 'the config file'}.`, { cause: installError });
|
|
1124
|
+
}
|
|
1125
|
+
throw installError;
|
|
1126
|
+
}
|
|
1127
|
+
// The transaction is committed. Cleanup cannot turn success into a
|
|
1128
|
+
// false failure, so leave a recoverable backup and warn if removal is
|
|
1129
|
+
// blocked instead of reporting that initialization failed.
|
|
1130
|
+
for (const backup of [
|
|
1131
|
+
schemaBackedUp ? schemaBackup : null,
|
|
1132
|
+
configBackedUp ? configBackup : null,
|
|
1133
|
+
]) {
|
|
1134
|
+
if (!backup)
|
|
1135
|
+
continue;
|
|
1136
|
+
try {
|
|
1137
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
1138
|
+
}
|
|
1139
|
+
catch (cleanupError) {
|
|
1140
|
+
console.error(`Warning: initialization succeeded, but the backup at ${backup} could not be removed: ${cleanupError.message}`);
|
|
1141
|
+
}
|
|
973
1142
|
}
|
|
974
|
-
// Create default template content
|
|
975
|
-
const templateContent = createDefaultTemplate(artifact.id);
|
|
976
|
-
fs.writeFileSync(templatePath, templateContent);
|
|
977
1143
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
if (fs.existsSync(configPath)) {
|
|
982
|
-
const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml');
|
|
983
|
-
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
984
|
-
const config = parseYaml(configContent) || {};
|
|
985
|
-
config.defaultSchema = name;
|
|
986
|
-
fs.writeFileSync(configPath, stringifyYaml2(config));
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
try {
|
|
1146
|
+
fs.rmSync(schemaStagingDir, { recursive: true, force: true });
|
|
987
1147
|
}
|
|
988
|
-
|
|
989
|
-
//
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1148
|
+
catch {
|
|
1149
|
+
// Best-effort cleanup must not hide the operation's real error.
|
|
1150
|
+
}
|
|
1151
|
+
throw error;
|
|
1152
|
+
}
|
|
1153
|
+
finally {
|
|
1154
|
+
if (configStagingDir) {
|
|
1155
|
+
try {
|
|
1156
|
+
fs.rmSync(configStagingDir, { recursive: true, force: true });
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
// Best-effort cleanup. A committed config has already moved out.
|
|
993
1160
|
}
|
|
994
|
-
fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name }));
|
|
995
1161
|
}
|
|
996
1162
|
}
|
|
997
1163
|
if (spinner)
|