mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
fix(show): include requirement and scenario names in JSON (#1972)
show --json described each requirement as its SHALL sentence and each scenario as its bullets. The parser read both headers and dropped them, so a JSON reader could not name a requirement the way archive matches it. Requirements and scenarios now carry a name, normalized by the same helpers archive and the MODIFIED scenario loss check use. The field is additive and optional in the schema. Closes #1971
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
`show --json` now includes each requirement's and scenario's `name`, matching the header names archive uses, so JSON readers can cite a requirement without parsing the markdown again (#1971).
|
||||
@@ -541,9 +541,11 @@ A change with `--json` is delta-shaped:
|
||||
"operation": "ADDED",
|
||||
"description": "Add requirement: The API SHALL limit each client to 100 requests per minute.",
|
||||
"requirement": {
|
||||
"name": "Rate limit",
|
||||
"text": "The API SHALL limit each client to 100 requests per minute.",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Client exceeds the limit",
|
||||
"rawText": "- **WHEN** a client sends its 101st request within a minute\n- **THEN** the API responds 429"
|
||||
}
|
||||
]
|
||||
@@ -558,9 +560,11 @@ A change with `--json` is delta-shaped:
|
||||
}
|
||||
```
|
||||
|
||||
Each requirement carries its `name`, the header text after `Requirement:`. This is the name archive matches MODIFIED, REMOVED and RENAMED entries against. Each scenario carries its `name`, the header text after `Scenario:`. A closing `#` run on either header is not part of the name.
|
||||
|
||||
`--json --diff` keeps this top-level shape. A MODIFIED delta gains a `diff` string, a `warning` string, or both. Other operations are unchanged. An empty `diff` string means the main and delta blocks are textually identical.
|
||||
|
||||
A spec with `--json` lists its requirements with scenarios:
|
||||
A spec with `--json` lists its requirements with scenarios. Requirements and scenarios carry the same `name` fields as change JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -570,9 +574,11 @@ A spec with `--json` lists its requirements with scenarios:
|
||||
"requirementCount": 1,
|
||||
"requirements": [
|
||||
{
|
||||
"name": "Health endpoint",
|
||||
"text": "The API SHALL expose a health endpoint.",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Health check succeeds",
|
||||
"rawText": "- **WHEN** a client requests GET /health\n- **THEN** the API responds 200"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -52,7 +52,7 @@ deliberately remains the compatibility bare array documented in §4.13:
|
||||
`warnings` (omitted when empty) reports directories under `changes/` that are not changes. Today the only code is `nested_change_directory`: a namespace folder wrapping change directories, which OpenSpec cannot address because a change is always a directory directly under `changes/`. The same entry carries `nested` on the listed change, whose `status` is then meaningless. Do not treat such an entry as a change; report the message and leave the directories alone.
|
||||
|
||||
### 4.2 `show <item> --json`
|
||||
Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`.
|
||||
Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. A requirement, in a spec or in a change delta's `requirement`/`requirements`, is `{ "name", "text", "scenarios": [ { "name", "rawText" } ] }`. A requirement `name` is its header without `Requirement:` and without a closing `#` run, the exact name archive matches MODIFIED/REMOVED/RENAMED entries against. A scenario `name` is its level-4 header without `Scenario:` and without a closing `#` run, the name the MODIFIED scenario-loss check compares.
|
||||
|
||||
### 4.3 `validate --json`
|
||||
`{ "items": [ { "id", "type": "change"|"spec", "valid", "issues": [ { "level", "path", "message", "line"?, "column"? } ], "durationMs" } ], "summary": { "totals": {items,passed,failed}, "byType": {...} }, "version": "1.0", "root" }`. Exit 1 when any item fails.
|
||||
|
||||
@@ -66,6 +66,7 @@ function filterSpec(spec: Spec, options: ShowOptions): Spec {
|
||||
? [spec.requirements[requirementIndex]]
|
||||
: spec.requirements
|
||||
).map(req => ({
|
||||
name: req.name,
|
||||
text: req.text,
|
||||
scenarios: includeScenarios ? req.scenarios : [],
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';
|
||||
import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
|
||||
import { normalizeRequirementName, scenarioNameFromHeaderText } from './requirement-blocks.js';
|
||||
|
||||
export interface Section {
|
||||
level: number;
|
||||
@@ -160,6 +161,9 @@ export class MarkdownParser {
|
||||
const scenarios = this.parseScenarios(child);
|
||||
|
||||
requirements.push({
|
||||
// The name archive matches on, so a JSON reader can cite a requirement
|
||||
// the way a MODIFIED or REMOVED header must.
|
||||
name: normalizeRequirementName(child.title.replace(/^Requirement:\s*/i, '')),
|
||||
text,
|
||||
scenarios,
|
||||
});
|
||||
@@ -176,6 +180,7 @@ export class MarkdownParser {
|
||||
// body is not a scenario; the delta counter applies the same rule.
|
||||
if (hasScenarioBody(scenarioSection.content)) {
|
||||
scenarios.push({
|
||||
name: scenarioNameFromHeaderText(scenarioSection.title),
|
||||
rawText: scenarioSection.content
|
||||
});
|
||||
}
|
||||
|
||||
@@ -630,8 +630,16 @@ function scenarioHeaderAt(lines: string[], mask: boolean[], index: number): bool
|
||||
* ATX-closed, one not) are not mistaken for a dropped scenario.
|
||||
*/
|
||||
function scenarioNameAt(line: string): string {
|
||||
return line
|
||||
.replace(SCENARIO_HEADER, '')
|
||||
return scenarioNameFromHeaderText(line.replace(SCENARIO_HEADER, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* scenarioNameAt for header text whose leading `####` is already gone, as the
|
||||
* section parser (MarkdownParser) holds it, so `show --json` names a scenario
|
||||
* exactly as the MODIFIED loss check does.
|
||||
*/
|
||||
export function scenarioNameFromHeaderText(headerText: string): string {
|
||||
return headerText
|
||||
// Optional ATX closing sequence. CommonMark only treats a trailing `#` run
|
||||
// as a close when it is preceded by a space or tab — not any Unicode space —
|
||||
// so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
|
||||
|
||||
@@ -2,10 +2,16 @@ import { z } from 'zod';
|
||||
import { VALIDATION_MESSAGES } from '../validation/constants.js';
|
||||
|
||||
export const ScenarioSchema = z.object({
|
||||
// Header text without `####`, the closing `#` run, and the `Scenario:`
|
||||
// prefix. Optional so objects built outside the parser still validate.
|
||||
name: z.string().optional(),
|
||||
rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),
|
||||
});
|
||||
|
||||
export const RequirementSchema = z.object({
|
||||
// Header text without `###` and the `Requirement:` prefix: the name archive
|
||||
// matches MODIFIED, REMOVED and RENAMED entries against.
|
||||
name: z.string().optional(),
|
||||
// SHALL/MUST body-keyword enforcement lives in the imperative validator
|
||||
// (Validator.applySpecRules), not here: the parser collapses the requirement
|
||||
// header into `text`, so a Zod refine on `text` cannot tell "keyword in header
|
||||
|
||||
@@ -90,6 +90,30 @@ The system SHALL process credit card payments securely`;
|
||||
}
|
||||
});
|
||||
|
||||
it('names each requirement and scenario in show --json', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const json = JSON.parse(execFileSync('node', [openspecBin, 'show', 'auth', '--type', 'spec', '--json'], {
|
||||
encoding: 'utf-8'
|
||||
}));
|
||||
expect(json.requirements.map((r: any) => r.name)).toEqual(['User Authentication', 'Password Reset']);
|
||||
expect(json.requirements[0].scenarios[0].name).toBe('Successful login');
|
||||
|
||||
const one = JSON.parse(execFileSync('node', [openspecBin, 'show', 'auth', '--type', 'spec', '--json', '-r', '2'], {
|
||||
encoding: 'utf-8'
|
||||
}));
|
||||
expect(one.requirements[0].name).toBe('Password Reset');
|
||||
|
||||
const bare = JSON.parse(execFileSync('node', [openspecBin, 'show', 'auth', '--type', 'spec', '--json', '--no-scenarios'], {
|
||||
encoding: 'utf-8'
|
||||
}));
|
||||
expect(bare.requirements[1].name).toBe('Password Reset');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter to show only requirements with --requirements flag (JSON only)', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
|
||||
@@ -138,4 +138,42 @@ describe('ChangeParser', () => {
|
||||
expect(change.deltas[0].requirement?.text).toBe('The system SHALL do a thing.');
|
||||
});
|
||||
});
|
||||
|
||||
it('names each delta requirement and scenario as archive matches them', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const specsDir = path.join(dir, 'specs', 'auth');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- **auth:** Update login`;
|
||||
const deltaSpec = [
|
||||
'## ADDED Requirements',
|
||||
'',
|
||||
'### Requirement: Session Timeout',
|
||||
'The system SHALL end idle sessions.',
|
||||
'',
|
||||
'#### Scenario: Idle for an hour',
|
||||
'- **WHEN** a session is idle for an hour',
|
||||
'- **THEN** it ends',
|
||||
'',
|
||||
'## MODIFIED Requirements',
|
||||
'',
|
||||
'### Requirement: User Login ##',
|
||||
'The system SHALL log users in with a password.',
|
||||
'',
|
||||
'#### Scenario: Valid credentials',
|
||||
'- **WHEN** a user signs in',
|
||||
'- **THEN** a session starts',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec, 'utf8');
|
||||
|
||||
const change = await new ChangeParser(content, dir).parseChangeWithDeltas('test-change');
|
||||
const byOperation = Object.fromEntries(change.deltas.map((d) => [d.operation, d.requirement]));
|
||||
|
||||
expect(byOperation.ADDED?.name).toBe('Session Timeout');
|
||||
expect(byOperation.ADDED?.scenarios[0].name).toBe('Idle for an hour');
|
||||
// The closing run is not part of the name archive matches on.
|
||||
expect(byOperation.MODIFIED?.name).toBe('User Login');
|
||||
expect(byOperation.MODIFIED?.scenarios[0].name).toBe('Valid credentials');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -556,4 +556,51 @@ Widgets are the one thing this product cannot assemble today.
|
||||
expect(titled).toEqual(untitled);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requirement and scenario names', () => {
|
||||
const spec = (requirements: string) =>
|
||||
`## Purpose\nNames for JSON readers.\n\n## Requirements\n\n${requirements}`;
|
||||
|
||||
it('names a requirement and its scenarios without the header prefixes', () => {
|
||||
const parsed = new MarkdownParser(spec(`### Requirement: User Login
|
||||
The system SHALL log users in.
|
||||
|
||||
#### Scenario: Valid credentials
|
||||
- **WHEN** a user signs in
|
||||
- **THEN** a session starts
|
||||
|
||||
#### Scenario: Wrong password
|
||||
- **WHEN** the password is wrong
|
||||
- **THEN** no session starts`)).parseSpec('auth');
|
||||
|
||||
expect(parsed.requirements[0].name).toBe('User Login');
|
||||
expect(parsed.requirements[0].scenarios.map((s) => s.name)).toEqual([
|
||||
'Valid credentials',
|
||||
'Wrong password',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops a closing # run the way archive does, but keeps a # inside the name', () => {
|
||||
const parsed = new MarkdownParser(spec(`### Requirement: Supports C# ###
|
||||
The system SHALL compile C#.
|
||||
|
||||
#### Scenario: Builds a C# project ##
|
||||
- **WHEN** a project is built
|
||||
- **THEN** it compiles`)).parseSpec('lang');
|
||||
|
||||
expect(parsed.requirements[0].name).toBe('Supports C#');
|
||||
expect(parsed.requirements[0].scenarios[0].name).toBe('Builds a C# project');
|
||||
});
|
||||
|
||||
it('names a level-4 header without the Scenario: prefix by its text', () => {
|
||||
const parsed = new MarkdownParser(spec(`### Requirement: Retries
|
||||
The system SHALL retry failed calls.
|
||||
|
||||
#### Edge case: zero retries
|
||||
- **WHEN** retries are set to 0
|
||||
- **THEN** the call runs once`)).parseSpec('net');
|
||||
|
||||
expect(parsed.requirements[0].scenarios[0].name).toBe('Edge case: zero retries');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user