@fission-ai/openspec 1.15.0 → 1.16.1
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/cli/index.js +1 -1
- 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/change-parser.js +3 -2
- package/dist/core/parsers/code-fence.js +9 -1
- package/dist/core/parsers/markdown-parser.js +3 -2
- package/dist/core/parsers/requirement-blocks.d.ts +48 -0
- package/dist/core/parsers/requirement-blocks.js +69 -26
- 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 +3 -4
- 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/specs-apply.js +11 -3
- package/dist/core/store/git.d.ts +22 -1
- package/dist/core/store/git.js +59 -4
- package/dist/core/validation/purpose-placeholder.js +1 -1
- package/dist/core/validation/validator.d.ts +27 -2
- package/dist/core/validation/validator.js +62 -5
- package/dist/core/version-check.d.ts +5 -2
- package/dist/core/version-check.js +115 -43
- package/package.json +6 -6
- 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
|
|
package/dist/cli/index.js
CHANGED
|
@@ -123,7 +123,7 @@ program
|
|
|
123
123
|
.action(async (options) => {
|
|
124
124
|
const { getCliInstallInfo, checkForCliUpdate, getCliUpdateCommand, canSelfUpgrade, buildVersionReportLines, } = await import('../core/version-check.js');
|
|
125
125
|
const install = getCliInstallInfo();
|
|
126
|
-
const update = options.check ? await checkForCliUpdate() : undefined;
|
|
126
|
+
const update = options.check ? await checkForCliUpdate({ requested: true }) : undefined;
|
|
127
127
|
const command = update?.status === 'available' ? getCliUpdateCommand(install) : null;
|
|
128
128
|
const output = {
|
|
129
129
|
schemaVersion: 1,
|
|
@@ -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. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MarkdownParser } from './markdown-parser.js';
|
|
2
2
|
import { buildCodeFenceMask } from './requirement-text.js';
|
|
3
|
-
import { parseDeltaSpec } from './requirement-blocks.js';
|
|
3
|
+
import { parseDeltaSpec, stripClosingSequence } from './requirement-blocks.js';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { promises as fs } from 'fs';
|
|
6
6
|
import { discoverSpecFiles } from '../../utils/spec-discovery.js';
|
|
@@ -171,7 +171,8 @@ export class ChangeParser extends MarkdownParser {
|
|
|
171
171
|
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
172
172
|
if (headerMatch) {
|
|
173
173
|
const level = headerMatch[1].length;
|
|
174
|
-
|
|
174
|
+
// `## Purpose ##` is the same heading as `## Purpose`.
|
|
175
|
+
const title = stripClosingSequence(headerMatch[2]);
|
|
175
176
|
const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);
|
|
176
177
|
const section = {
|
|
177
178
|
level,
|
|
@@ -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,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
|
|
2
|
-
import { normalizeRequirementName, scenarioNameFromHeaderText } from './requirement-blocks.js';
|
|
2
|
+
import { normalizeRequirementName, scenarioNameFromHeaderText, stripClosingSequence } from './requirement-blocks.js';
|
|
3
3
|
export class MarkdownParser {
|
|
4
4
|
lines;
|
|
5
5
|
codeFenceLineMask;
|
|
@@ -68,7 +68,8 @@ export class MarkdownParser {
|
|
|
68
68
|
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
69
69
|
if (headerMatch) {
|
|
70
70
|
const level = headerMatch[1].length;
|
|
71
|
-
|
|
71
|
+
// `## Purpose ##` is the same heading as `## Purpose`.
|
|
72
|
+
const title = stripClosingSequence(headerMatch[2]);
|
|
72
73
|
const content = this.getContentUntilNextHeader(i + 1, level);
|
|
73
74
|
const section = {
|
|
74
75
|
level,
|
|
@@ -10,6 +10,15 @@ export interface RequirementsSectionParts {
|
|
|
10
10
|
bodyBlocks: RequirementBlock[];
|
|
11
11
|
after: string;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Drop an ATX heading's closing run of `#`s: `## Purpose ##` renders as
|
|
15
|
+
* `Purpose`, so the run is not part of the title. As for scenario names, only a
|
|
16
|
+
* run preceded by a space or tab closes the heading, so `C#` keeps its `#`, and
|
|
17
|
+
* `[ \t]` rather than `\s` keeps an NBSP-separated run in the title.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripClosingSequence(title: string): string;
|
|
20
|
+
/** The main spec's `## Requirements` header, closing run allowed (`## Requirements ##`). */
|
|
21
|
+
export declare const REQUIREMENTS_SECTION_HEADER_REGEX: RegExp;
|
|
13
22
|
export declare function normalizeRequirementName(name: string): string;
|
|
14
23
|
/**
|
|
15
24
|
* Case- and whitespace-insensitive fold of a requirement name. Requirement
|
|
@@ -105,7 +114,26 @@ export interface DeltaPlan {
|
|
|
105
114
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
106
115
|
*/
|
|
107
116
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
117
|
+
/**
|
|
118
|
+
* True when an orphaned requirement sits under a `## ... Requirements` header
|
|
119
|
+
* that is not one of the four delta sections - `## DEPRECATED Requirements`,
|
|
120
|
+
* `## Add Requirements`, `## Requirements`. Such a header reads as a delta
|
|
121
|
+
* operation, so its requirements were meant to be applied: validate reports
|
|
122
|
+
* them as an ERROR and archive refuses (#2195). Orphans under prose sections
|
|
123
|
+
* (`## Notes`, `## Behavior`) or above the first `## ` stay warnings.
|
|
124
|
+
*/
|
|
125
|
+
export declare function isUnrecognizedDeltaSection(section: string | null): boolean;
|
|
108
126
|
export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
|
|
127
|
+
/**
|
|
128
|
+
* One `#### ` child of a requirement block: its rendered name and its lines,
|
|
129
|
+
* header included. Exported because the validator's scenario-step check walks
|
|
130
|
+
* the same blocks the loss comparison does — a second reader of the same
|
|
131
|
+
* format is how the two drifted apart before.
|
|
132
|
+
*/
|
|
133
|
+
export interface ScenarioBlock {
|
|
134
|
+
name: string;
|
|
135
|
+
raw: string;
|
|
136
|
+
}
|
|
109
137
|
/** Both directions of the scenario-name comparison, plus the two totals. */
|
|
110
138
|
export interface ScenarioNameDiff {
|
|
111
139
|
/** Names the current block has that the incoming block does not cover. */
|
|
@@ -153,4 +181,24 @@ export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
|
|
|
153
181
|
* exactly as the MODIFIED loss check does.
|
|
154
182
|
*/
|
|
155
183
|
export declare function scenarioNameFromHeaderText(headerText: string): string;
|
|
184
|
+
/**
|
|
185
|
+
* Whether a `#### ` header line carries the canonical `Scenario:` label. Any
|
|
186
|
+
* level-4 child counts as a scenario for counting and loss purposes (the
|
|
187
|
+
* parity the SCENARIO_HEADER comment protects), but the step check warns only
|
|
188
|
+
* about blocks that claim to be scenarios: a `#### Notes` child is prose on
|
|
189
|
+
* purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
190
|
+
*/
|
|
191
|
+
export declare function isScenarioLabeledHeader(headerLine: string): boolean;
|
|
192
|
+
/**
|
|
193
|
+
* Every `#### ` child of a requirement block, header line included in `raw`,
|
|
194
|
+
* in document order. A scenario is ANY non-fenced `#### ` header, matching the
|
|
195
|
+
* spec path's SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly —
|
|
196
|
+
* not only `#### Scenario:`. The two MUST agree: a level-4 child whose header
|
|
197
|
+
* is not literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the
|
|
198
|
+
* spec path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
199
|
+
* the loss check and be deleted by archive with no error (the parity the
|
|
200
|
+
* SCENARIO_HEADER comment warns not to break). A `####` inside a fenced example
|
|
201
|
+
* is masked out, matching countScenarios.
|
|
202
|
+
*/
|
|
203
|
+
export declare function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[];
|
|
156
204
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
|
|
2
|
+
/**
|
|
3
|
+
* Drop an ATX heading's closing run of `#`s: `## Purpose ##` renders as
|
|
4
|
+
* `Purpose`, so the run is not part of the title. As for scenario names, only a
|
|
5
|
+
* run preceded by a space or tab closes the heading, so `C#` keeps its `#`, and
|
|
6
|
+
* `[ \t]` rather than `\s` keeps an NBSP-separated run in the title.
|
|
7
|
+
*/
|
|
8
|
+
export function stripClosingSequence(title) {
|
|
9
|
+
return title.replace(/[ \t]+#+[ \t]*$/, '').trim();
|
|
10
|
+
}
|
|
11
|
+
/** The main spec's `## Requirements` header, closing run allowed (`## Requirements ##`). */
|
|
12
|
+
export const REQUIREMENTS_SECTION_HEADER_REGEX = /^##\s+Requirements(?:[ \t]+#+)?\s*$/i;
|
|
2
13
|
export function normalizeRequirementName(name) {
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// only a run preceded by a space or tab closes the heading, so `C#` keeps its
|
|
6
|
-
// `#`, and `[ \t]` rather than `\s` keeps an NBSP-separated run in the name.
|
|
7
|
-
return name.replace(/[ \t]+#+[ \t]*$/, '').trim();
|
|
14
|
+
// `### Requirement: Foo ###` names the requirement `Foo`.
|
|
15
|
+
return stripClosingSequence(name);
|
|
8
16
|
}
|
|
9
17
|
/**
|
|
10
18
|
* Case- and whitespace-insensitive fold of a requirement name. Requirement
|
|
@@ -26,7 +34,7 @@ export function extractRequirementsSection(content) {
|
|
|
26
34
|
const normalized = normalizeLineEndings(content);
|
|
27
35
|
const lines = normalized.split('\n');
|
|
28
36
|
const fenceMask = buildCodeFenceMask(lines);
|
|
29
|
-
const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] &&
|
|
37
|
+
const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && REQUIREMENTS_SECTION_HEADER_REGEX.test(l));
|
|
30
38
|
if (reqHeaderIndex === -1) {
|
|
31
39
|
// No requirements section; create an empty one at the end
|
|
32
40
|
const before = content.trimEnd();
|
|
@@ -154,6 +162,19 @@ export function parseDeltaSpec(content) {
|
|
|
154
162
|
* under it would be dropped without this warning.
|
|
155
163
|
*/
|
|
156
164
|
const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requirements', 'REMOVED Requirements', 'RENAMED Requirements'].map((title) => title.toLowerCase()));
|
|
165
|
+
/**
|
|
166
|
+
* True when an orphaned requirement sits under a `## ... Requirements` header
|
|
167
|
+
* that is not one of the four delta sections - `## DEPRECATED Requirements`,
|
|
168
|
+
* `## Add Requirements`, `## Requirements`. Such a header reads as a delta
|
|
169
|
+
* operation, so its requirements were meant to be applied: validate reports
|
|
170
|
+
* them as an ERROR and archive refuses (#2195). Orphans under prose sections
|
|
171
|
+
* (`## Notes`, `## Behavior`) or above the first `## ` stay warnings.
|
|
172
|
+
*/
|
|
173
|
+
export function isUnrecognizedDeltaSection(section) {
|
|
174
|
+
return (section !== null &&
|
|
175
|
+
/\brequirements?$/i.test(section) &&
|
|
176
|
+
!DELTA_SECTION_TITLES.has(section.toLowerCase()));
|
|
177
|
+
}
|
|
157
178
|
/**
|
|
158
179
|
* Every canonical `### Requirement:` header that is not inside a delta section,
|
|
159
180
|
* in document order.
|
|
@@ -161,10 +182,12 @@ const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requiremen
|
|
|
161
182
|
* Walks the whole file rather than the parsed sections so a requirement written
|
|
162
183
|
* ABOVE the first `## ` header is reported too - it is dropped just as silently
|
|
163
184
|
* as one under `## Notes`. Fenced lines are skipped, so a requirement shown
|
|
164
|
-
* inside a markdown example is not mistaken for an authored one
|
|
185
|
+
* inside a markdown example is not mistaken for an authored one, and so are
|
|
186
|
+
* HTML comments, so a commented-out draft is not reported as dropped.
|
|
165
187
|
*/
|
|
166
188
|
function findOrphanedRequirements(lines, fenceMask) {
|
|
167
189
|
const orphans = [];
|
|
190
|
+
const masked = maskHtmlComments(lines, fenceMask);
|
|
168
191
|
let section = null;
|
|
169
192
|
for (let i = 0; i < lines.length; i++) {
|
|
170
193
|
if (fenceMask[i])
|
|
@@ -172,12 +195,12 @@ function findOrphanedRequirements(lines, fenceMask) {
|
|
|
172
195
|
// The same `## ` test splitTopLevelSections uses, so both agree on sections.
|
|
173
196
|
const sectionMatch = lines[i].match(/^(##)\s+(.+)$/);
|
|
174
197
|
if (sectionMatch) {
|
|
175
|
-
section = sectionMatch[2]
|
|
198
|
+
section = stripClosingSequence(sectionMatch[2]);
|
|
176
199
|
continue;
|
|
177
200
|
}
|
|
178
201
|
if (section !== null && DELTA_SECTION_TITLES.has(section.toLowerCase()))
|
|
179
202
|
continue;
|
|
180
|
-
const header =
|
|
203
|
+
const header = masked[i].match(REQUIREMENT_HEADER_REGEX);
|
|
181
204
|
if (header) {
|
|
182
205
|
orphans.push({
|
|
183
206
|
name: normalizeRequirementName(header[1]),
|
|
@@ -201,18 +224,24 @@ export function describeUnappliedContent(content, sentenceStart = false) {
|
|
|
201
224
|
* (warned about separately), REMOVED/RENAMED bodies, and stray `###` headers
|
|
202
225
|
* before the first requirement (INFO since #498) are not reported.
|
|
203
226
|
*/
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Blank out comments outside fences, keeping line count; an unterminated one
|
|
229
|
+
* runs to EOF. Fenced lines keep their text, so a `<!--` in a code sample never
|
|
230
|
+
* hides what follows the fence.
|
|
231
|
+
*/
|
|
232
|
+
function maskHtmlComments(lines, fenceMask) {
|
|
210
233
|
const comments = lines
|
|
211
234
|
.map((line, i) => (fenceMask[i] ? '' : line))
|
|
212
235
|
.join('\n')
|
|
213
236
|
.replace(/<!--[\s\S]*?(?:--!?>|$)/g, (m) => m.replace(/[^\n]/g, ' '))
|
|
214
237
|
.split('\n');
|
|
215
|
-
|
|
238
|
+
return lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
|
|
239
|
+
}
|
|
240
|
+
function findUnappliedContent(lines, fenceMask) {
|
|
241
|
+
// Comments only decide whether a line holds text: sections come from the
|
|
242
|
+
// same split the merge reads, where a `## ` line inside a comment is still a
|
|
243
|
+
// boundary.
|
|
244
|
+
const masked = maskHtmlComments(lines, fenceMask);
|
|
216
245
|
const sections = splitTopLevelSections(lines, fenceMask);
|
|
217
246
|
const firstText = (from, to, skip) => {
|
|
218
247
|
for (let i = from; i < to; i++)
|
|
@@ -260,7 +289,8 @@ function splitTopLevelSections(lines, fenceMask) {
|
|
|
260
289
|
continue;
|
|
261
290
|
const m = lines[i].match(/^(##)\s+(.+)$/);
|
|
262
291
|
if (m) {
|
|
263
|
-
|
|
292
|
+
// `## ADDED Requirements ##` is the same section as `## ADDED Requirements`.
|
|
293
|
+
indices.push({ title: stripClosingSequence(m[2]), index: i });
|
|
264
294
|
}
|
|
265
295
|
}
|
|
266
296
|
for (let i = 0; i < indices.length; i++) {
|
|
@@ -547,16 +577,29 @@ export function scenarioNameFromHeaderText(headerText) {
|
|
|
547
577
|
.replace(/^Scenario:\s*/i, '')
|
|
548
578
|
.trim();
|
|
549
579
|
}
|
|
550
|
-
|
|
580
|
+
/**
|
|
581
|
+
* Whether a `#### ` header line carries the canonical `Scenario:` label. Any
|
|
582
|
+
* level-4 child counts as a scenario for counting and loss purposes (the
|
|
583
|
+
* parity the SCENARIO_HEADER comment protects), but the step check warns only
|
|
584
|
+
* about blocks that claim to be scenarios: a `#### Notes` child is prose on
|
|
585
|
+
* purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
586
|
+
*/
|
|
587
|
+
export function isScenarioLabeledHeader(headerLine) {
|
|
588
|
+
return /^Scenario:\s*/i.test(headerLine.replace(SCENARIO_HEADER, ''));
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Every `#### ` child of a requirement block, header line included in `raw`,
|
|
592
|
+
* in document order. A scenario is ANY non-fenced `#### ` header, matching the
|
|
593
|
+
* spec path's SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly —
|
|
594
|
+
* not only `#### Scenario:`. The two MUST agree: a level-4 child whose header
|
|
595
|
+
* is not literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the
|
|
596
|
+
* spec path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
597
|
+
* the loss check and be deleted by archive with no error (the parity the
|
|
598
|
+
* SCENARIO_HEADER comment warns not to break). A `####` inside a fenced example
|
|
599
|
+
* is masked out, matching countScenarios.
|
|
600
|
+
*/
|
|
601
|
+
export function parseScenarioBlocks(requirementRaw) {
|
|
551
602
|
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
603
|
const mask = buildCodeFenceMask(lines);
|
|
561
604
|
const scenarios = [];
|
|
562
605
|
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
|