@fission-ai/openspec 1.15.0 → 1.16.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 +5 -7
- package/dist/commands/completion.js +4 -2
- package/dist/commands/validate.js +23 -0
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
- package/dist/core/completions/installers/zsh-installer.js +64 -19
- package/dist/core/id.d.ts +8 -0
- package/dist/core/id.js +15 -0
- package/dist/core/parsers/code-fence.js +9 -1
- package/dist/core/parsers/requirement-blocks.d.ts +30 -0
- package/dist/core/parsers/requirement-blocks.js +22 -9
- package/dist/core/parsers/requirement-text.d.ts +14 -0
- package/dist/core/parsers/requirement-text.js +39 -0
- package/dist/core/parsers/spec-structure.js +1 -1
- package/dist/core/project-config.d.ts +10 -0
- package/dist/core/project-config.js +26 -0
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +37 -0
- package/dist/core/store/git.d.ts +22 -1
- package/dist/core/store/git.js +59 -4
- package/dist/core/validation/validator.d.ts +27 -2
- package/dist/core/validation/validator.js +58 -4
- package/package.json +3 -3
- package/schemas/spec-driven/schema.yaml +1 -0
package/README.md
CHANGED
|
@@ -117,21 +117,19 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
|
|
|
117
117
|
|
|
118
118
|
## Quick Start
|
|
119
119
|
|
|
120
|
-
Install OpenSpec.
|
|
121
|
-
|
|
122
|
-
macOS and Linux:
|
|
120
|
+
Install OpenSpec. On macOS or Linux, in your terminal:
|
|
123
121
|
|
|
124
122
|
```bash
|
|
125
|
-
curl -fsSL https://
|
|
123
|
+
curl -fsSL https://openspec.dev/cli/install.sh | sh
|
|
126
124
|
```
|
|
127
125
|
|
|
128
|
-
Windows
|
|
126
|
+
On Windows, in PowerShell:
|
|
129
127
|
|
|
130
128
|
```powershell
|
|
131
|
-
irm https://
|
|
129
|
+
irm https://openspec.dev/cli/install.ps1 | iex
|
|
132
130
|
```
|
|
133
131
|
|
|
134
|
-
Prefer a package manager? `npm install -g @fission-ai/openspec@latest` (needs Node.js
|
|
132
|
+
Prefer a package manager? `npm install -g @fission-ai/openspec@latest` (needs Node.js 22.12.0 or higher) or the official [Homebrew formula](https://formulae.brew.sh/formula/openspec), `brew install openspec`.
|
|
135
133
|
|
|
136
134
|
Then navigate to your project directory and initialize:
|
|
137
135
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CompletionFactory } from '../core/completions/factory.js';
|
|
2
|
+
import { zshrcDisplayPath } from '../core/completions/installers/zsh-installer.js';
|
|
2
3
|
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
|
3
4
|
import { detectShell } from '../utils/shell-detection.js';
|
|
4
5
|
import { CompletionProvider } from '../core/completions/completion-provider.js';
|
|
@@ -113,7 +114,7 @@ export class CompletionCommand {
|
|
|
113
114
|
const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;
|
|
114
115
|
if (configWasUpdated) {
|
|
115
116
|
const configPaths = {
|
|
116
|
-
zsh:
|
|
117
|
+
zsh: zshrcDisplayPath(),
|
|
117
118
|
bash: '~/.bashrc',
|
|
118
119
|
fish: '~/.config/fish/config.fish',
|
|
119
120
|
powershell: '$PROFILE',
|
|
@@ -174,7 +175,8 @@ export class CompletionCommand {
|
|
|
174
175
|
const { confirm } = await import('@inquirer/prompts');
|
|
175
176
|
// Get shell-specific config file path
|
|
176
177
|
const configPaths = {
|
|
177
|
-
|
|
178
|
+
// Uninstall also clears a block an older install left in ~/.zshrc.
|
|
179
|
+
zsh: zshrcDisplayPath() === '~/.zshrc' ? '~/.zshrc' : `${zshrcDisplayPath()} (and ~/.zshrc if an older install wrote there)`,
|
|
178
180
|
bash: '~/.bashrc',
|
|
179
181
|
fish: 'Fish configuration', // Fish doesn't modify profile, just removes script file
|
|
180
182
|
powershell: '$PROFILE',
|
|
@@ -11,6 +11,7 @@ import { promises as fs } from 'fs';
|
|
|
11
11
|
import { getTaskProgressDetailForChange } from '../utils/task-progress.js';
|
|
12
12
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
13
13
|
import { folderStyleNameProblem } from '../core/id.js';
|
|
14
|
+
import { readConfigParseError } from '../core/project-config.js';
|
|
14
15
|
/** Findings are a distinct report, not a partial full-v1 items collection. */
|
|
15
16
|
export function projectValidationFindings(full, scope) {
|
|
16
17
|
const itemFindings = full.items.filter(item => item.issues.length > 0);
|
|
@@ -66,6 +67,28 @@ export class ValidateCommand {
|
|
|
66
67
|
if (!root) {
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
70
|
+
// Every other command ignores an unreadable or unparseable config with a
|
|
71
|
+
// warning, so validate is where CI has to catch it (#1892).
|
|
72
|
+
const configError = readConfigParseError(root.path);
|
|
73
|
+
if (configError) {
|
|
74
|
+
const unreadable = configError.kind === 'read';
|
|
75
|
+
const message = unreadable
|
|
76
|
+
? `Could not read ${configError.filePath} (${configError.message}).`
|
|
77
|
+
: `Could not parse ${configError.filePath} (${configError.message}).`;
|
|
78
|
+
const fix = unreadable
|
|
79
|
+
? `Make ${configError.filePath} a readable file, or remove it.`
|
|
80
|
+
: `Fix the YAML syntax in ${configError.filePath}.`;
|
|
81
|
+
const code = unreadable ? 'unreadable_config' : 'unparseable_config';
|
|
82
|
+
if (options.json) {
|
|
83
|
+
console.log(JSON.stringify({ status: [{ severity: 'error', code, message, fix }] }, null, 2));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.error(`Error: ${message}`);
|
|
87
|
+
console.error(`Fix: ${fix}`);
|
|
88
|
+
}
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
69
92
|
const interactive = isInteractive(options);
|
|
70
93
|
// Archived-task linting is its own scope: it checks task completion of
|
|
71
94
|
// already-archived changes, not delta specs (whose operations are already
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { InstallationResult } from '../factory.js';
|
|
2
|
+
/** The .zshrc zsh will read, as shown to the user: `~/.zshrc` unless ZDOTDIR moves it. */
|
|
3
|
+
export declare function zshrcDisplayPath(homeDir?: string): string;
|
|
2
4
|
/**
|
|
3
5
|
* Installer for Zsh completion scripts.
|
|
4
6
|
* Supports both Oh My Zsh and standard Zsh configurations.
|
|
@@ -47,6 +49,11 @@ export declare class ZshInstaller {
|
|
|
47
49
|
* @returns Path to .zshrc
|
|
48
50
|
*/
|
|
49
51
|
private getZshrcPath;
|
|
52
|
+
/**
|
|
53
|
+
* Every .zshrc that may hold an OpenSpec block: the one zsh reads, plus
|
|
54
|
+
* ~/.zshrc, where installs before #2138 wrote it even under ZDOTDIR.
|
|
55
|
+
*/
|
|
56
|
+
private getZshrcCandidates;
|
|
50
57
|
/**
|
|
51
58
|
* Generate .zshrc configuration content
|
|
52
59
|
*
|
|
@@ -72,9 +79,10 @@ export declare class ZshInstaller {
|
|
|
72
79
|
* Remove .zshrc configuration
|
|
73
80
|
* Used during uninstallation
|
|
74
81
|
*
|
|
82
|
+
* @param zshrcPath - The .zshrc to clean; defaults to the one zsh reads
|
|
75
83
|
* @returns true if removed successfully, false otherwise
|
|
76
84
|
*/
|
|
77
|
-
removeZshrcConfig(): Promise<boolean>;
|
|
85
|
+
removeZshrcConfig(zshrcPath?: string): Promise<boolean>;
|
|
78
86
|
/**
|
|
79
87
|
* Install the completion script
|
|
80
88
|
*
|
|
@@ -3,6 +3,23 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { FileSystemUtils } from '../../../utils/file-system.js';
|
|
5
5
|
import { shellSingleQuote } from './shell-quote.js';
|
|
6
|
+
/**
|
|
7
|
+
* zsh reads its startup files from $ZDOTDIR when it is set, and from $HOME
|
|
8
|
+
* only otherwise, so a block written to ~/.zshrc under an XDG-style
|
|
9
|
+
* ZDOTDIR is never loaded (#2138).
|
|
10
|
+
*/
|
|
11
|
+
function zshrcDir(homeDir) {
|
|
12
|
+
const zdotdir = process.env.ZDOTDIR;
|
|
13
|
+
// zsh does not expand `~` in ZDOTDIR, and path.join would resolve a relative
|
|
14
|
+
// or `~/...` value against the current directory, writing a stray .zshrc
|
|
15
|
+
// into the user's project. Only an absolute ZDOTDIR moves the file.
|
|
16
|
+
return zdotdir && path.isAbsolute(zdotdir) ? zdotdir : homeDir;
|
|
17
|
+
}
|
|
18
|
+
/** The .zshrc zsh will read, as shown to the user: `~/.zshrc` unless ZDOTDIR moves it. */
|
|
19
|
+
export function zshrcDisplayPath(homeDir = os.homedir()) {
|
|
20
|
+
const zshrcPath = path.join(zshrcDir(homeDir), '.zshrc');
|
|
21
|
+
return zshrcPath === path.join(homeDir, '.zshrc') ? '~/.zshrc' : zshrcPath;
|
|
22
|
+
}
|
|
6
23
|
/**
|
|
7
24
|
* Installer for Zsh completion scripts.
|
|
8
25
|
* Supports both Oh My Zsh and standard Zsh configurations.
|
|
@@ -99,7 +116,14 @@ export class ZshInstaller {
|
|
|
99
116
|
* @returns Path to .zshrc
|
|
100
117
|
*/
|
|
101
118
|
getZshrcPath() {
|
|
102
|
-
return path.join(this.homeDir, '.zshrc');
|
|
119
|
+
return path.join(zshrcDir(this.homeDir), '.zshrc');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Every .zshrc that may hold an OpenSpec block: the one zsh reads, plus
|
|
123
|
+
* ~/.zshrc, where installs before #2138 wrote it even under ZDOTDIR.
|
|
124
|
+
*/
|
|
125
|
+
getZshrcCandidates() {
|
|
126
|
+
return [...new Set([this.getZshrcPath(), path.join(this.homeDir, '.zshrc')])];
|
|
103
127
|
}
|
|
104
128
|
/**
|
|
105
129
|
* Generate .zshrc configuration content
|
|
@@ -150,9 +174,8 @@ export class ZshInstaller {
|
|
|
150
174
|
*
|
|
151
175
|
* @returns true if .zshrc exists and has markers
|
|
152
176
|
*/
|
|
153
|
-
async hasZshrcConfig() {
|
|
177
|
+
async hasZshrcConfig(zshrcPath = this.getZshrcPath()) {
|
|
154
178
|
try {
|
|
155
|
-
const zshrcPath = this.getZshrcPath();
|
|
156
179
|
const content = await fs.readFile(zshrcPath, 'utf-8');
|
|
157
180
|
return content.includes(this.ZSHRC_MARKERS.start) && content.includes(this.ZSHRC_MARKERS.end);
|
|
158
181
|
}
|
|
@@ -164,11 +187,11 @@ export class ZshInstaller {
|
|
|
164
187
|
* Remove .zshrc configuration
|
|
165
188
|
* Used during uninstallation
|
|
166
189
|
*
|
|
190
|
+
* @param zshrcPath - The .zshrc to clean; defaults to the one zsh reads
|
|
167
191
|
* @returns true if removed successfully, false otherwise
|
|
168
192
|
*/
|
|
169
|
-
async removeZshrcConfig() {
|
|
193
|
+
async removeZshrcConfig(zshrcPath = this.getZshrcPath()) {
|
|
170
194
|
try {
|
|
171
|
-
const zshrcPath = this.getZshrcPath();
|
|
172
195
|
// Check if file exists
|
|
173
196
|
try {
|
|
174
197
|
await fs.access(zshrcPath);
|
|
@@ -224,16 +247,24 @@ export class ZshInstaller {
|
|
|
224
247
|
try {
|
|
225
248
|
const existingContent = await fs.readFile(targetPath, 'utf-8');
|
|
226
249
|
if (existingContent === completionScript) {
|
|
227
|
-
// Already installed and up to date
|
|
250
|
+
// Already installed and up to date. Still make sure the .zshrc zsh
|
|
251
|
+
// reads has the block: an install made before #2138 under ZDOTDIR
|
|
252
|
+
// wrote it to ~/.zshrc, and re-running install is how that's fixed.
|
|
253
|
+
const zshrcConfigured = isOhMyZsh
|
|
254
|
+
? false
|
|
255
|
+
: await this.configureZshrc(path.dirname(targetPath));
|
|
228
256
|
return {
|
|
229
257
|
success: true,
|
|
230
258
|
installedPath: targetPath,
|
|
231
259
|
isOhMyZsh,
|
|
260
|
+
zshrcConfigured,
|
|
232
261
|
message: 'Completion script is already installed (up to date)',
|
|
233
|
-
instructions:
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
instructions: isOhMyZsh || zshrcConfigured
|
|
263
|
+
? [
|
|
264
|
+
'The completion script is already installed and up to date.',
|
|
265
|
+
'If completions are not working, try: exec zsh',
|
|
266
|
+
]
|
|
267
|
+
: this.generateInstructions(false, targetPath),
|
|
237
268
|
};
|
|
238
269
|
}
|
|
239
270
|
// File exists but content is different - this is an update
|
|
@@ -336,11 +367,11 @@ export class ZshInstaller {
|
|
|
336
367
|
}
|
|
337
368
|
else {
|
|
338
369
|
const completionsDir = path.dirname(installedPath);
|
|
339
|
-
const zshrcPath =
|
|
370
|
+
const zshrcPath = this.getZshrcPath();
|
|
340
371
|
return [
|
|
341
372
|
'Completion script installed to ~/.zsh/completions/',
|
|
342
373
|
'',
|
|
343
|
-
|
|
374
|
+
`To enable completions, add the following to your ${zshrcDisplayPath(this.homeDir)} file:`,
|
|
344
375
|
'',
|
|
345
376
|
` # Add completions directory to fpath`,
|
|
346
377
|
` fpath=(${shellSingleQuote(completionsDir)} $fpath)`,
|
|
@@ -375,11 +406,19 @@ export class ZshInstaller {
|
|
|
375
406
|
}
|
|
376
407
|
// Try to remove .zshrc configuration (only for standard Zsh)
|
|
377
408
|
let zshrcWasPresent = false;
|
|
378
|
-
|
|
409
|
+
const cleanedZshrcs = [];
|
|
410
|
+
const failedZshrcs = [];
|
|
379
411
|
if (!isOhMyZsh) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
412
|
+
for (const zshrcPath of this.getZshrcCandidates()) {
|
|
413
|
+
if (await this.hasZshrcConfig(zshrcPath)) {
|
|
414
|
+
zshrcWasPresent = true;
|
|
415
|
+
if (await this.removeZshrcConfig(zshrcPath)) {
|
|
416
|
+
cleanedZshrcs.push(zshrcPath);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
failedZshrcs.push(zshrcPath);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
383
422
|
}
|
|
384
423
|
}
|
|
385
424
|
if (!scriptRemoved && !zshrcWasPresent) {
|
|
@@ -392,11 +431,17 @@ export class ZshInstaller {
|
|
|
392
431
|
if (scriptRemoved) {
|
|
393
432
|
messages.push(`Completion script removed from ${targetPath}`);
|
|
394
433
|
}
|
|
395
|
-
|
|
396
|
-
|
|
434
|
+
const label = (zshrcPath) => zshrcPath === path.join(this.homeDir, '.zshrc') ? '~/.zshrc' : zshrcPath;
|
|
435
|
+
for (const zshrcPath of cleanedZshrcs) {
|
|
436
|
+
messages.push(`Removed OpenSpec configuration from ${label(zshrcPath)}`);
|
|
437
|
+
}
|
|
438
|
+
// A block left behind keeps loading the completions, so say so instead
|
|
439
|
+
// of reporting a clean uninstall.
|
|
440
|
+
for (const zshrcPath of failedZshrcs) {
|
|
441
|
+
messages.push(`Could not remove the OpenSpec block from ${label(zshrcPath)}; delete the lines between # OPENSPEC:START and # OPENSPEC:END by hand`);
|
|
397
442
|
}
|
|
398
443
|
return {
|
|
399
|
-
success:
|
|
444
|
+
success: failedZshrcs.length === 0,
|
|
400
445
|
message: messages.join('. '),
|
|
401
446
|
};
|
|
402
447
|
}
|
package/dist/core/id.d.ts
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export declare const KEBAB_ID_REGEX: RegExp;
|
|
6
6
|
export declare function isKebabId(value: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort normalization of an arbitrary name into the kebab grammar:
|
|
9
|
+
* lowercases, splits camelCase/PascalCase boundaries, and folds every
|
|
10
|
+
* non-alphanumeric run into a single hyphen. Used to turn a git repository
|
|
11
|
+
* name into a candidate store id; the result is only ever a candidate and
|
|
12
|
+
* is validated with {@link isKebabId} before use.
|
|
13
|
+
*/
|
|
14
|
+
export declare function toKebabCase(value: string): string;
|
|
7
15
|
/** Human rendering of the grammar, shared so the wording never forks. */
|
|
8
16
|
export declare const KEBAB_ID_DESCRIPTION = "must be kebab-case with lowercase letters, numbers, and single hyphen separators";
|
|
9
17
|
/** The fix-line twin of KEBAB_ID_DESCRIPTION, shared for the same reason. */
|
package/dist/core/id.js
CHANGED
|
@@ -6,6 +6,21 @@ export const KEBAB_ID_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
|
6
6
|
export function isKebabId(value) {
|
|
7
7
|
return KEBAB_ID_REGEX.test(value);
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort normalization of an arbitrary name into the kebab grammar:
|
|
11
|
+
* lowercases, splits camelCase/PascalCase boundaries, and folds every
|
|
12
|
+
* non-alphanumeric run into a single hyphen. Used to turn a git repository
|
|
13
|
+
* name into a candidate store id; the result is only ever a candidate and
|
|
14
|
+
* is validated with {@link isKebabId} before use.
|
|
15
|
+
*/
|
|
16
|
+
export function toKebabCase(value) {
|
|
17
|
+
return value
|
|
18
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
19
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
20
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.toLowerCase();
|
|
23
|
+
}
|
|
9
24
|
/** Human rendering of the grammar, shared so the wording never forks. */
|
|
10
25
|
export const KEBAB_ID_DESCRIPTION = 'must be kebab-case with lowercase letters, numbers, and single hyphen separators';
|
|
11
26
|
/** The fix-line twin of KEBAB_ID_DESCRIPTION, shared for the same reason. */
|
|
@@ -8,10 +8,18 @@
|
|
|
8
8
|
* requirements during validation and archiving.
|
|
9
9
|
*/
|
|
10
10
|
function getFenceMarker(line) {
|
|
11
|
-
|
|
11
|
+
// Unanchored at the end: `.` does not match `\r`, so `(.*)$` would reject
|
|
12
|
+
// every opener on a line that still carries a CRLF ending.
|
|
13
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)/);
|
|
12
14
|
if (!fenceMatch) {
|
|
13
15
|
return null;
|
|
14
16
|
}
|
|
17
|
+
// CommonMark: a backtick fence's info string may not contain a backtick, so
|
|
18
|
+
// a line like ```npm test``` is inline code, not a fence opener. Treating it
|
|
19
|
+
// as one masked every following line, hiding later requirements.
|
|
20
|
+
if (fenceMatch[1][0] === '`' && fenceMatch[2].includes('`')) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
15
23
|
return {
|
|
16
24
|
marker: fenceMatch[1][0],
|
|
17
25
|
length: fenceMatch[1].length,
|
|
@@ -106,6 +106,16 @@ export interface DeltaPlan {
|
|
|
106
106
|
*/
|
|
107
107
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
108
108
|
export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
|
|
109
|
+
/**
|
|
110
|
+
* One `#### ` child of a requirement block: its rendered name and its lines,
|
|
111
|
+
* header included. Exported because the validator's scenario-step check walks
|
|
112
|
+
* the same blocks the loss comparison does — a second reader of the same
|
|
113
|
+
* format is how the two drifted apart before.
|
|
114
|
+
*/
|
|
115
|
+
export interface ScenarioBlock {
|
|
116
|
+
name: string;
|
|
117
|
+
raw: string;
|
|
118
|
+
}
|
|
109
119
|
/** Both directions of the scenario-name comparison, plus the two totals. */
|
|
110
120
|
export interface ScenarioNameDiff {
|
|
111
121
|
/** Names the current block has that the incoming block does not cover. */
|
|
@@ -153,4 +163,24 @@ export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
|
|
|
153
163
|
* exactly as the MODIFIED loss check does.
|
|
154
164
|
*/
|
|
155
165
|
export declare function scenarioNameFromHeaderText(headerText: string): string;
|
|
166
|
+
/**
|
|
167
|
+
* Whether a `#### ` header line carries the canonical `Scenario:` label. Any
|
|
168
|
+
* level-4 child counts as a scenario for counting and loss purposes (the
|
|
169
|
+
* parity the SCENARIO_HEADER comment protects), but the step check warns only
|
|
170
|
+
* about blocks that claim to be scenarios: a `#### Notes` child is prose on
|
|
171
|
+
* purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
172
|
+
*/
|
|
173
|
+
export declare function isScenarioLabeledHeader(headerLine: string): boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Every `#### ` child of a requirement block, header line included in `raw`,
|
|
176
|
+
* in document order. A scenario is ANY non-fenced `#### ` header, matching the
|
|
177
|
+
* spec path's SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly —
|
|
178
|
+
* not only `#### Scenario:`. The two MUST agree: a level-4 child whose header
|
|
179
|
+
* is not literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the
|
|
180
|
+
* spec path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
181
|
+
* the loss check and be deleted by archive with no error (the parity the
|
|
182
|
+
* SCENARIO_HEADER comment warns not to break). A `####` inside a fenced example
|
|
183
|
+
* is masked out, matching countScenarios.
|
|
184
|
+
*/
|
|
185
|
+
export declare function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[];
|
|
156
186
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -547,16 +547,29 @@ export function scenarioNameFromHeaderText(headerText) {
|
|
|
547
547
|
.replace(/^Scenario:\s*/i, '')
|
|
548
548
|
.trim();
|
|
549
549
|
}
|
|
550
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Whether a `#### ` header line carries the canonical `Scenario:` label. Any
|
|
552
|
+
* level-4 child counts as a scenario for counting and loss purposes (the
|
|
553
|
+
* parity the SCENARIO_HEADER comment protects), but the step check warns only
|
|
554
|
+
* about blocks that claim to be scenarios: a `#### Notes` child is prose on
|
|
555
|
+
* purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
556
|
+
*/
|
|
557
|
+
export function isScenarioLabeledHeader(headerLine) {
|
|
558
|
+
return /^Scenario:\s*/i.test(headerLine.replace(SCENARIO_HEADER, ''));
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Every `#### ` child of a requirement block, header line included in `raw`,
|
|
562
|
+
* in document order. A scenario is ANY non-fenced `#### ` header, matching the
|
|
563
|
+
* spec path's SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly —
|
|
564
|
+
* not only `#### Scenario:`. The two MUST agree: a level-4 child whose header
|
|
565
|
+
* is not literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the
|
|
566
|
+
* spec path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
567
|
+
* the loss check and be deleted by archive with no error (the parity the
|
|
568
|
+
* SCENARIO_HEADER comment warns not to break). A `####` inside a fenced example
|
|
569
|
+
* is masked out, matching countScenarios.
|
|
570
|
+
*/
|
|
571
|
+
export function parseScenarioBlocks(requirementRaw) {
|
|
551
572
|
const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
552
|
-
// A scenario is ANY non-fenced `#### ` header, matching the spec path's
|
|
553
|
-
// SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
|
|
554
|
-
// `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
|
|
555
|
-
// literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
|
|
556
|
-
// path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
557
|
-
// this loss check and be deleted by archive with no error (the parity the
|
|
558
|
-
// SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
|
|
559
|
-
// example is masked out, matching countScenarios.
|
|
560
573
|
const mask = buildCodeFenceMask(lines);
|
|
561
574
|
const scenarios = [];
|
|
562
575
|
let index = 0;
|
|
@@ -62,6 +62,20 @@ export declare function extractRequirementText(headerTitle: string, bodyLines: s
|
|
|
62
62
|
* example is not a real scenario and is not counted.
|
|
63
63
|
*/
|
|
64
64
|
export declare function countScenarios(bodyLines: string[]): number;
|
|
65
|
+
/** The two halves of a scenario, as carried by its non-fenced body lines. */
|
|
66
|
+
export interface ScenarioStepCoverage {
|
|
67
|
+
hasTrigger: boolean;
|
|
68
|
+
hasOutcome: boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether a scenario block (header line included, as `parseScenarioBlocks`
|
|
72
|
+
* returns it) carries a trigger step (**WHEN** or **GIVEN**) and an outcome
|
|
73
|
+
* step (**THEN**) outside fenced examples. `AND` only continues a step that is
|
|
74
|
+
* already there, so neither half can be carried by an `AND` line; a keyword
|
|
75
|
+
* inside a fenced block is documentation, not a step — the same masking
|
|
76
|
+
* `extractRequirementBody` applies to requirement text.
|
|
77
|
+
*/
|
|
78
|
+
export declare function scenarioStepCoverage(scenarioRaw: string): ScenarioStepCoverage;
|
|
65
79
|
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
66
80
|
export declare function countEmptyScenarios(bodyLines: string[]): number;
|
|
67
81
|
//# sourceMappingURL=requirement-text.d.ts.map
|
|
@@ -101,6 +101,45 @@ export function extractRequirementText(headerTitle, bodyLines) {
|
|
|
101
101
|
export function countScenarios(bodyLines) {
|
|
102
102
|
return readScenarioBodies(bodyLines).filter(hasScenarioBody).length;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* A step keyword as it is actually written: bolded in any case, with or
|
|
106
|
+
* without a colon inside the bold (`**WHEN**`, `**Given:**`), or bare and
|
|
107
|
+
* uppercase (`WHEN`). Bold carries the step-ness, so its case can be liberal;
|
|
108
|
+
* a bare word must be uppercase to count — lowercase `when`/`then` are
|
|
109
|
+
* ordinary English prose, and the missing-SHALL/MUST check is uppercase-only
|
|
110
|
+
* for the same reason. Bold is `**` or `__`; the bare pattern cannot catch
|
|
111
|
+
* `__WHEN__`, because `_` is a word character and `\b` never fires beside it.
|
|
112
|
+
*/
|
|
113
|
+
const BOLD_TRIGGER_STEP = /(?:\*\*|__)(?:WHEN|GIVEN):?(?:\*\*|__)/i;
|
|
114
|
+
const BARE_TRIGGER_STEP = /\b(?:WHEN|GIVEN)\b/;
|
|
115
|
+
const BOLD_OUTCOME_STEP = /(?:\*\*|__)THEN:?(?:\*\*|__)/i;
|
|
116
|
+
const BARE_OUTCOME_STEP = /\bTHEN\b/;
|
|
117
|
+
/**
|
|
118
|
+
* Whether a scenario block (header line included, as `parseScenarioBlocks`
|
|
119
|
+
* returns it) carries a trigger step (**WHEN** or **GIVEN**) and an outcome
|
|
120
|
+
* step (**THEN**) outside fenced examples. `AND` only continues a step that is
|
|
121
|
+
* already there, so neither half can be carried by an `AND` line; a keyword
|
|
122
|
+
* inside a fenced block is documentation, not a step — the same masking
|
|
123
|
+
* `extractRequirementBody` applies to requirement text.
|
|
124
|
+
*/
|
|
125
|
+
export function scenarioStepCoverage(scenarioRaw) {
|
|
126
|
+
const lines = scenarioRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
127
|
+
const mask = buildCodeFenceMask(lines);
|
|
128
|
+
let hasTrigger = false;
|
|
129
|
+
let hasOutcome = false;
|
|
130
|
+
// Line 0 is the scenario's own header: a keyword in the title
|
|
131
|
+
// (`#### Scenario: What happens THEN`) is a name, not a step.
|
|
132
|
+
for (let i = 1; i < lines.length; i++) {
|
|
133
|
+
if (mask[i])
|
|
134
|
+
continue;
|
|
135
|
+
const line = lines[i];
|
|
136
|
+
if (BOLD_TRIGGER_STEP.test(line) || BARE_TRIGGER_STEP.test(line))
|
|
137
|
+
hasTrigger = true;
|
|
138
|
+
if (BOLD_OUTCOME_STEP.test(line) || BARE_OUTCOME_STEP.test(line))
|
|
139
|
+
hasOutcome = true;
|
|
140
|
+
}
|
|
141
|
+
return { hasTrigger, hasOutcome };
|
|
142
|
+
}
|
|
104
143
|
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
105
144
|
export function countEmptyScenarios(bodyLines) {
|
|
106
145
|
return readScenarioBodies(bodyLines).filter((body) => !hasScenarioBody(body)).length;
|
|
@@ -3,7 +3,7 @@ import { normalizeRequirementName } from './requirement-blocks.js';
|
|
|
3
3
|
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
|
4
4
|
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
|
5
5
|
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
6
|
-
const REQUIREMENT_HEADER = /^###\s
|
|
6
|
+
const REQUIREMENT_HEADER = /^###\s*Requirement:\s*(.+)\s*$/i;
|
|
7
7
|
export function findMainSpecStructureIssues(content) {
|
|
8
8
|
const normalized = content.replace(/\r\n?/g, '\n');
|
|
9
9
|
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
|
|
@@ -70,6 +70,16 @@ export declare const MAX_CONTEXT_SIZE: number;
|
|
|
70
70
|
* @returns Parsed config or null if file doesn't exist
|
|
71
71
|
*/
|
|
72
72
|
export declare function readProjectConfig(projectRoot: string): ProjectConfig | null;
|
|
73
|
+
/**
|
|
74
|
+
* Warning-silent check for a config file that cannot be read or is not valid
|
|
75
|
+
* YAML at all. `readProjectConfig` ignores such a file with a warning, so
|
|
76
|
+
* `validate` uses this to fail instead and give CI a non-zero exit (#1892).
|
|
77
|
+
*/
|
|
78
|
+
export declare function readConfigParseError(projectRoot: string): {
|
|
79
|
+
kind: 'read' | 'parse';
|
|
80
|
+
filePath: string;
|
|
81
|
+
message: string;
|
|
82
|
+
} | null;
|
|
73
83
|
/**
|
|
74
84
|
* Validate artifact IDs in rules against the artifacts of every available
|
|
75
85
|
* schema. The `rules:` map is global, but each change can use a different
|
|
@@ -364,6 +364,32 @@ export function readProjectConfig(projectRoot) {
|
|
|
364
364
|
return null;
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Warning-silent check for a config file that cannot be read or is not valid
|
|
369
|
+
* YAML at all. `readProjectConfig` ignores such a file with a warning, so
|
|
370
|
+
* `validate` uses this to fail instead and give CI a non-zero exit (#1892).
|
|
371
|
+
*/
|
|
372
|
+
export function readConfigParseError(projectRoot) {
|
|
373
|
+
const filePath = resolveConfigFilePath(projectRoot);
|
|
374
|
+
if (filePath === null) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const firstLine = (error) => error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
378
|
+
let content;
|
|
379
|
+
try {
|
|
380
|
+
content = readFileSync(filePath, 'utf-8');
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
return { kind: 'read', filePath, message: firstLine(error) };
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
parseYaml(content);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
return { kind: 'parse', filePath, message: firstLine(error) };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
367
393
|
function configPathForWarnings(projectRoot) {
|
|
368
394
|
return resolveConfigFilePath(projectRoot) ?? path.join(projectRoot, 'openspec', 'config.yaml');
|
|
369
395
|
}
|
|
@@ -79,6 +79,12 @@ export type RegisteredStoreInspection = {
|
|
|
79
79
|
problems: string;
|
|
80
80
|
};
|
|
81
81
|
export declare function inspectRegisteredStore(id: string, storeRoot: string): Promise<RegisteredStoreInspection>;
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the OpenSpec root for a command, in precedence order: `--store <id>`,
|
|
84
|
+
* the nearest local `openspec/` directory, the global `defaultStore`, then the
|
|
85
|
+
* registered-store hint (which names a store matching the repo's origin, if
|
|
86
|
+
* any) or an implicit root.
|
|
87
|
+
*/
|
|
82
88
|
export declare function resolveOpenSpecRoot(options?: ResolveOpenSpecRootOptions): Promise<ResolvedOpenSpecRoot>;
|
|
83
89
|
export interface RootOutput {
|
|
84
90
|
path: string;
|
|
@@ -26,6 +26,8 @@ import * as path from 'node:path';
|
|
|
26
26
|
import { StoreError } from './store/errors.js';
|
|
27
27
|
import { getStoreMetadataPath, listStoreRegistryEntries, readStoreRegistryState, readOptionalStoreMetadataState, validateStoreId, } from './store/foundation.js';
|
|
28
28
|
import { getStoreRootForBackend } from './store/registry.js';
|
|
29
|
+
import { gitOriginUrl, repoNameFromGitUrl } from './store/git.js';
|
|
30
|
+
import { toKebabCase, isKebabId } from './id.js';
|
|
29
31
|
import { inspectOpenSpecRoot } from './openspec-root.js';
|
|
30
32
|
import { findRepoPlanningRootSync } from './planning-home.js';
|
|
31
33
|
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
|
|
@@ -251,6 +253,32 @@ async function resolveDefaultStoreRoot(id, globalDataDir) {
|
|
|
251
253
|
throw error;
|
|
252
254
|
}
|
|
253
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Git-remote store suggestion (#2076): the registered store whose id matches
|
|
258
|
+
* this repo's origin name, or null. Only ever named in the no-root error, never
|
|
259
|
+
* selected: a basename is not enough authority to redirect writes, because
|
|
260
|
+
* `company-a/api` and `company-b/api` both match a store called `api`.
|
|
261
|
+
*/
|
|
262
|
+
async function suggestStoreFromGitRemote(startPath, registeredIds) {
|
|
263
|
+
// `git -C <path>` needs a directory; a file entry path would make the probe
|
|
264
|
+
// fail. canonicalDirectory resolves that.
|
|
265
|
+
const url = await gitOriginUrl(canonicalDirectory(startPath));
|
|
266
|
+
if (!url)
|
|
267
|
+
return null;
|
|
268
|
+
const repoName = repoNameFromGitUrl(url);
|
|
269
|
+
if (!repoName)
|
|
270
|
+
return null;
|
|
271
|
+
const candidateId = toKebabCase(repoName);
|
|
272
|
+
if (!candidateId || !isKebabId(candidateId))
|
|
273
|
+
return null;
|
|
274
|
+
return registeredIds.includes(candidateId) ? candidateId : null;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the OpenSpec root for a command, in precedence order: `--store <id>`,
|
|
278
|
+
* the nearest local `openspec/` directory, the global `defaultStore`, then the
|
|
279
|
+
* registered-store hint (which names a store matching the repo's origin, if
|
|
280
|
+
* any) or an implicit root.
|
|
281
|
+
*/
|
|
254
282
|
export async function resolveOpenSpecRoot(options = {}) {
|
|
255
283
|
if (options.storePath !== undefined) {
|
|
256
284
|
throw new RootSelectionError('--store-path is not supported. Register the path with openspec store register <path>, then select it with --store <id>.', 'store_path_not_supported', {
|
|
@@ -284,6 +312,15 @@ export async function resolveOpenSpecRoot(options = {}) {
|
|
|
284
312
|
? listStoreRegistryEntries(registry).map((entry) => entry.id)
|
|
285
313
|
: [];
|
|
286
314
|
if (registeredIds.length > 0) {
|
|
315
|
+
// Only spawn git on this failure path: a repo whose origin name matches a
|
|
316
|
+
// registered store gets that store suggested, never selected.
|
|
317
|
+
const suggested = await suggestStoreFromGitRemote(startPath, registeredIds);
|
|
318
|
+
if (suggested) {
|
|
319
|
+
throw new RootSelectionError(`No OpenSpec root found in the current directory or its ancestors. This repo's origin name matches the registered store "${suggested}". To use it, rerun with --store ${suggested}, or add "store: ${suggested}" to openspec/config.yaml so every command here uses it.`, 'no_root_with_registered_stores', {
|
|
320
|
+
target: 'openspec.root',
|
|
321
|
+
fix: `Rerun with --store ${suggested}, or add "store: ${suggested}" to openspec/config.yaml.`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
287
324
|
throw new RootSelectionError(`No OpenSpec root found in the current directory or its ancestors. Registered stores: ${registeredIds.join(', ')}. Pass --store <id> to use one, or run openspec init to create a local root.`, 'no_root_with_registered_stores', {
|
|
288
325
|
target: 'openspec.root',
|
|
289
326
|
fix: `Rerun with --store <id> (registered: ${registeredIds.join(', ')}) or run openspec init.`,
|
package/dist/core/store/git.d.ts
CHANGED
|
@@ -47,11 +47,32 @@ export declare function isProbeResourceFailure(error: unknown): boolean;
|
|
|
47
47
|
export declare function gitHasCommits(storeRoot: string): Promise<boolean | null>;
|
|
48
48
|
export declare function gitHasUncommittedChanges(storeRoot: string): Promise<boolean | null>;
|
|
49
49
|
export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Env vars that point git at a repository other than the one `-C` names.
|
|
52
|
+
* Git sets some of them for hooks, so a CLI run from a hook inherits them.
|
|
53
|
+
*/
|
|
54
|
+
export declare const GIT_REPO_LOCATION_ENV_VARS: readonly ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR"];
|
|
55
|
+
/** A copy of `env` without {@link GIT_REPO_LOCATION_ENV_VARS}. */
|
|
56
|
+
export declare function envWithoutGitRepoLocation(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
50
57
|
/**
|
|
51
58
|
* The configured origin URL, read from local Git config only — never a
|
|
52
|
-
* network touch. Null when there is no repository or no origin.
|
|
59
|
+
* network touch. Null when there is no repository or no origin. Inherited
|
|
60
|
+
* repository-location vars are dropped so the answer is always about
|
|
61
|
+
* `storeRoot`: root selection runs this from wherever the CLI started,
|
|
62
|
+
* including inside another repository's git hook.
|
|
53
63
|
*/
|
|
54
64
|
export declare function gitOriginUrl(storeRoot: string): Promise<string | null>;
|
|
65
|
+
/**
|
|
66
|
+
* The repository name from an origin URL, with no host or `.git` suffix.
|
|
67
|
+
* Handles the three common remote spellings:
|
|
68
|
+
*
|
|
69
|
+
* - `git@github.com:user/repo.git`
|
|
70
|
+
* - `https://github.com/user/repo.git`
|
|
71
|
+
* - `ssh://git@github.com/user/repo.git`
|
|
72
|
+
*
|
|
73
|
+
* Returns the last path segment, or `null` when the URL has no parseable name.
|
|
74
|
+
*/
|
|
75
|
+
export declare function repoNameFromGitUrl(url: string): string | null;
|
|
55
76
|
export interface GitTrackingDrift {
|
|
56
77
|
ahead: number;
|
|
57
78
|
behind: number;
|
package/dist/core/store/git.js
CHANGED
|
@@ -137,9 +137,9 @@ export function isProbeResourceFailure(error) {
|
|
|
137
137
|
code === 'ETIMEDOUT' ||
|
|
138
138
|
(killed === true && signal === 'SIGKILL'));
|
|
139
139
|
}
|
|
140
|
-
async function gitProbe(storeRoot, args) {
|
|
140
|
+
async function gitProbe(storeRoot, args, options = {}) {
|
|
141
141
|
try {
|
|
142
|
-
const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args]);
|
|
142
|
+
const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args], options);
|
|
143
143
|
return stdout;
|
|
144
144
|
}
|
|
145
145
|
catch (error) {
|
|
@@ -175,15 +175,70 @@ export async function gitHasRemote(storeRoot) {
|
|
|
175
175
|
const stdout = await gitProbe(storeRoot, ['remote']);
|
|
176
176
|
return stdout === null ? null : stdout.trim().length > 0;
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Env vars that point git at a repository other than the one `-C` names.
|
|
180
|
+
* Git sets some of them for hooks, so a CLI run from a hook inherits them.
|
|
181
|
+
*/
|
|
182
|
+
export const GIT_REPO_LOCATION_ENV_VARS = [
|
|
183
|
+
'GIT_DIR',
|
|
184
|
+
'GIT_WORK_TREE',
|
|
185
|
+
'GIT_INDEX_FILE',
|
|
186
|
+
'GIT_OBJECT_DIRECTORY',
|
|
187
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
188
|
+
'GIT_COMMON_DIR',
|
|
189
|
+
];
|
|
190
|
+
/** A copy of `env` without {@link GIT_REPO_LOCATION_ENV_VARS}. */
|
|
191
|
+
export function envWithoutGitRepoLocation(env = process.env) {
|
|
192
|
+
const clean = { ...env };
|
|
193
|
+
for (const key of GIT_REPO_LOCATION_ENV_VARS) {
|
|
194
|
+
delete clean[key];
|
|
195
|
+
}
|
|
196
|
+
return clean;
|
|
197
|
+
}
|
|
178
198
|
/**
|
|
179
199
|
* The configured origin URL, read from local Git config only — never a
|
|
180
|
-
* network touch. Null when there is no repository or no origin.
|
|
200
|
+
* network touch. Null when there is no repository or no origin. Inherited
|
|
201
|
+
* repository-location vars are dropped so the answer is always about
|
|
202
|
+
* `storeRoot`: root selection runs this from wherever the CLI started,
|
|
203
|
+
* including inside another repository's git hook.
|
|
181
204
|
*/
|
|
182
205
|
export async function gitOriginUrl(storeRoot) {
|
|
183
|
-
const stdout = await gitProbe(storeRoot, ['remote', 'get-url', 'origin']
|
|
206
|
+
const stdout = await gitProbe(storeRoot, ['remote', 'get-url', 'origin'], {
|
|
207
|
+
env: envWithoutGitRepoLocation(),
|
|
208
|
+
});
|
|
184
209
|
const url = stdout?.trim();
|
|
185
210
|
return url ? url : null;
|
|
186
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* The repository name from an origin URL, with no host or `.git` suffix.
|
|
214
|
+
* Handles the three common remote spellings:
|
|
215
|
+
*
|
|
216
|
+
* - `git@github.com:user/repo.git`
|
|
217
|
+
* - `https://github.com/user/repo.git`
|
|
218
|
+
* - `ssh://git@github.com/user/repo.git`
|
|
219
|
+
*
|
|
220
|
+
* Returns the last path segment, or `null` when the URL has no parseable name.
|
|
221
|
+
*/
|
|
222
|
+
export function repoNameFromGitUrl(url) {
|
|
223
|
+
const cleaned = url.trim().replace(/\.git\/?$/i, '');
|
|
224
|
+
let path = null;
|
|
225
|
+
// URL-like: scheme://host[/user/repo]. The `://` is required, so scp-like
|
|
226
|
+
// `git@host:user/repo` (no scheme) never matches this branch.
|
|
227
|
+
const urlLike = cleaned.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+(?:\/(.*))?$/i);
|
|
228
|
+
if (urlLike) {
|
|
229
|
+
path = urlLike[1] ?? null;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// scp-like: git@host:user/repo
|
|
233
|
+
const scpLike = cleaned.match(/^(?:[\w.-]+@)?[\w.-]+:(.+)$/);
|
|
234
|
+
path = scpLike ? scpLike[1] : null;
|
|
235
|
+
}
|
|
236
|
+
if (!path)
|
|
237
|
+
return null;
|
|
238
|
+
const segments = path.split('/').filter(Boolean);
|
|
239
|
+
const last = segments[segments.length - 1];
|
|
240
|
+
return last && last !== '.' && last !== '..' ? last : null;
|
|
241
|
+
}
|
|
187
242
|
/**
|
|
188
243
|
* Ahead/behind counts of HEAD against its configured upstream tracking
|
|
189
244
|
* ref, read from local refs only — no fetch, no network. The comparison
|
|
@@ -12,8 +12,10 @@ export declare class Validator {
|
|
|
12
12
|
* Validate delta-formatted spec files under a change directory.
|
|
13
13
|
* Enforces:
|
|
14
14
|
* - At least one delta across all files
|
|
15
|
-
* - ADDED/MODIFIED: each requirement has at least one scenario;
|
|
16
|
-
*
|
|
15
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; a scenario
|
|
16
|
+
* with no WHEN/GIVEN or THEN step is guidance (WARNING) unless strict mode
|
|
17
|
+
* is enabled; missing English SHALL/MUST keywords are guidance unless
|
|
18
|
+
* strict mode is enabled
|
|
17
19
|
* - REMOVED: names only; no scenario/description required
|
|
18
20
|
* - RENAMED: pairs well-formed
|
|
19
21
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
@@ -90,6 +92,14 @@ export declare class Validator {
|
|
|
90
92
|
private findScenarioLossIssues;
|
|
91
93
|
private formatInvalidMarkerMessage;
|
|
92
94
|
private convertZodErrors;
|
|
95
|
+
/**
|
|
96
|
+
* Every guidance and error the main-spec path emits about one spec file
|
|
97
|
+
* (#1156, #243): structure, a placeholder or too-brief Purpose, requirement
|
|
98
|
+
* length, a requirement with no scenario, a missing English SHALL/MUST in the
|
|
99
|
+
* body, and a counted scenario with no trigger or outcome. The step check is
|
|
100
|
+
* the same one the delta path runs, on the same `#### ` blocks, so a scenario
|
|
101
|
+
* that asserts nothing is reported wherever it lives.
|
|
102
|
+
*/
|
|
93
103
|
private applySpecRules;
|
|
94
104
|
private applyChangeRules;
|
|
95
105
|
private enrichTopLevelError;
|
|
@@ -124,6 +134,21 @@ export declare class Validator {
|
|
|
124
134
|
* not count it. Empty when the block has no bare scenario header.
|
|
125
135
|
*/
|
|
126
136
|
private emptyScenarioHint;
|
|
137
|
+
/**
|
|
138
|
+
* A counted scenario whose body carries neither a **WHEN** (or **GIVEN**)
|
|
139
|
+
* step nor a **THEN** step can never fail: nothing triggers it and nothing
|
|
140
|
+
* is asserted (#2078). Read from the same blocks the scenario-loss comparison
|
|
141
|
+
* walks, so the two checks cannot disagree about what a scenario is.
|
|
142
|
+
* Only `Scenario:`-labeled blocks are checked: any level-4 child counts as a
|
|
143
|
+
* scenario for loss purposes, but an unlabeled `#### Notes` child is prose
|
|
144
|
+
* on purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
145
|
+
*
|
|
146
|
+
* A WARNING, like the missing SHALL/MUST keyword: plain `validate` and
|
|
147
|
+
* `archive` keep agreeing, and `--strict` — which already fails on any
|
|
148
|
+
* warning — is the bar that enforces it.
|
|
149
|
+
*/
|
|
150
|
+
private findScenarioStepIssues;
|
|
151
|
+
/** Render a list of section names as prose: `a`, `a and b`, `a, b and c`. */
|
|
127
152
|
private formatSectionList;
|
|
128
153
|
}
|
|
129
154
|
//# sourceMappingURL=validator.d.ts.map
|
|
@@ -5,8 +5,8 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
|
5
5
|
import { ChangeParser } from '../parsers/change-parser.js';
|
|
6
6
|
import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
|
|
7
7
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
8
|
-
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
|
|
9
|
-
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
|
|
8
|
+
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, parseScenarioBlocks, isScenarioLabeledHeader, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
|
|
9
|
+
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, hasScenarioBody, scenarioStepCoverage, } from '../parsers/requirement-text.js';
|
|
10
10
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
12
|
import { assertDiscoveredSpecFile } from '../../utils/spec-path-containment.js';
|
|
@@ -108,8 +108,10 @@ export class Validator {
|
|
|
108
108
|
* Validate delta-formatted spec files under a change directory.
|
|
109
109
|
* Enforces:
|
|
110
110
|
* - At least one delta across all files
|
|
111
|
-
* - ADDED/MODIFIED: each requirement has at least one scenario;
|
|
112
|
-
*
|
|
111
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; a scenario
|
|
112
|
+
* with no WHEN/GIVEN or THEN step is guidance (WARNING) unless strict mode
|
|
113
|
+
* is enabled; missing English SHALL/MUST keywords are guidance unless
|
|
114
|
+
* strict mode is enabled
|
|
113
115
|
* - REMOVED: names only; no scenario/description required
|
|
114
116
|
* - RENAMED: pairs well-formed
|
|
115
117
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
@@ -278,6 +280,7 @@ export class Validator {
|
|
|
278
280
|
if (scenarioCount < 1) {
|
|
279
281
|
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
280
282
|
}
|
|
283
|
+
issues.push(...this.findScenarioStepIssues(block.raw, `ADDED "${block.name}"`, entryPath));
|
|
281
284
|
}
|
|
282
285
|
// Validate MODIFIED
|
|
283
286
|
for (const block of plan.modified) {
|
|
@@ -310,6 +313,7 @@ export class Validator {
|
|
|
310
313
|
if (scenarioCount < 1) {
|
|
311
314
|
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
312
315
|
}
|
|
316
|
+
issues.push(...this.findScenarioStepIssues(block.raw, `MODIFIED "${block.name}"`, entryPath));
|
|
313
317
|
}
|
|
314
318
|
// Run archive's scenario-loss check here too, so the change fails at
|
|
315
319
|
// authoring time instead of days later at archive time (#1477).
|
|
@@ -698,6 +702,14 @@ export class Validator {
|
|
|
698
702
|
};
|
|
699
703
|
});
|
|
700
704
|
}
|
|
705
|
+
/**
|
|
706
|
+
* Every guidance and error the main-spec path emits about one spec file
|
|
707
|
+
* (#1156, #243): structure, a placeholder or too-brief Purpose, requirement
|
|
708
|
+
* length, a requirement with no scenario, a missing English SHALL/MUST in the
|
|
709
|
+
* body, and a counted scenario with no trigger or outcome. The step check is
|
|
710
|
+
* the same one the delta path runs, on the same `#### ` blocks, so a scenario
|
|
711
|
+
* that asserts nothing is reported wherever it lives.
|
|
712
|
+
*/
|
|
701
713
|
applySpecRules(spec, content) {
|
|
702
714
|
const issues = [];
|
|
703
715
|
for (const structuralIssue of findMainSpecStructureIssues(content)) {
|
|
@@ -767,6 +779,9 @@ export class Validator {
|
|
|
767
779
|
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name, true),
|
|
768
780
|
});
|
|
769
781
|
}
|
|
782
|
+
// Same step check the delta path runs, on the same blocks, so a scenario
|
|
783
|
+
// that asserts nothing is reported wherever it lives.
|
|
784
|
+
issues.push(...this.findScenarioStepIssues(block.raw, `Requirement "${block.name}"`, `requirements[${index}].scenarios`));
|
|
770
785
|
});
|
|
771
786
|
return issues;
|
|
772
787
|
}
|
|
@@ -941,6 +956,45 @@ export class Validator {
|
|
|
941
956
|
return '';
|
|
942
957
|
return ' (a scenario header with no body under it does not count; add its steps, e.g. "- **WHEN** ..." and "- **THEN** ...")';
|
|
943
958
|
}
|
|
959
|
+
/**
|
|
960
|
+
* A counted scenario whose body carries neither a **WHEN** (or **GIVEN**)
|
|
961
|
+
* step nor a **THEN** step can never fail: nothing triggers it and nothing
|
|
962
|
+
* is asserted (#2078). Read from the same blocks the scenario-loss comparison
|
|
963
|
+
* walks, so the two checks cannot disagree about what a scenario is.
|
|
964
|
+
* Only `Scenario:`-labeled blocks are checked: any level-4 child counts as a
|
|
965
|
+
* scenario for loss purposes, but an unlabeled `#### Notes` child is prose
|
|
966
|
+
* on purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
967
|
+
*
|
|
968
|
+
* A WARNING, like the missing SHALL/MUST keyword: plain `validate` and
|
|
969
|
+
* `archive` keep agreeing, and `--strict` — which already fails on any
|
|
970
|
+
* warning — is the bar that enforces it.
|
|
971
|
+
*/
|
|
972
|
+
findScenarioStepIssues(blockRaw, label, issuePath) {
|
|
973
|
+
const issues = [];
|
|
974
|
+
for (const scenario of parseScenarioBlocks(blockRaw)) {
|
|
975
|
+
// A header with no body is already an ERROR with its own hint; a step
|
|
976
|
+
// complaint on top of it would say nothing new. parseScenarioBlocks keeps
|
|
977
|
+
// the header line in `raw`, and hasScenarioBody takes the body alone.
|
|
978
|
+
const bodyLines = scenario.raw.split('\n').slice(1).join('\n');
|
|
979
|
+
if (!hasScenarioBody(bodyLines))
|
|
980
|
+
continue;
|
|
981
|
+
if (!isScenarioLabeledHeader(scenario.raw.split('\n')[0]))
|
|
982
|
+
continue;
|
|
983
|
+
const { hasTrigger, hasOutcome } = scenarioStepCoverage(scenario.raw);
|
|
984
|
+
if (hasTrigger && hasOutcome)
|
|
985
|
+
continue;
|
|
986
|
+
const missing = [!hasTrigger && 'a WHEN/GIVEN step', !hasOutcome && 'a THEN step']
|
|
987
|
+
.filter((part) => part !== false)
|
|
988
|
+
.join(' and ');
|
|
989
|
+
issues.push({
|
|
990
|
+
level: 'WARNING',
|
|
991
|
+
path: issuePath,
|
|
992
|
+
message: `${label}: scenario "${scenario.name}" is missing ${missing}. A scenario needs a trigger and an outcome, e.g. "- **WHEN** an invoice is voided" and "- **THEN** a credit note is issued".`,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
return issues;
|
|
996
|
+
}
|
|
997
|
+
/** Render a list of section names as prose: `a`, `a and b`, `a, b and c`. */
|
|
944
998
|
formatSectionList(sections) {
|
|
945
999
|
if (sections.length === 0)
|
|
946
1000
|
return '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fission-ai/openspec",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "AI-native system for spec-driven development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openspec",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"!dist/**/*.map"
|
|
39
39
|
],
|
|
40
40
|
"engines": {
|
|
41
|
-
"node": ">=
|
|
41
|
+
"node": ">=22.12.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@changesets/changelog-github": "^1.0.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"smol-toml": "^1.7.1",
|
|
51
51
|
"typescript": "^6.0.3",
|
|
52
52
|
"typescript-eslint": "^8.65.0",
|
|
53
|
-
"vite": "^
|
|
53
|
+
"vite": "^8.3.5",
|
|
54
54
|
"vitest": "^4.1.12"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
@@ -105,6 +105,7 @@ artifacts:
|
|
|
105
105
|
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
|
|
106
106
|
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
|
107
107
|
- Every requirement MUST have at least one scenario.
|
|
108
|
+
- Every scenario MUST have a WHEN/GIVEN step (its trigger) and a THEN step (its outcome). A scenario with only AND lines or plain prose still counts as a scenario, but `openspec validate` flags it as a warning: normal validation still passes, but `openspec validate --strict` fails on it.
|
|
108
109
|
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
|
|
109
110
|
|
|
110
111
|
New capabilities only: the delta spec's first section is `## Purpose` -
|