diff --git a/.changeset/show-json-names.md b/.changeset/show-json-names.md new file mode 100644 index 00000000..d883094c --- /dev/null +++ b/.changeset/show-json-names.md @@ -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). diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index f02c81ef..b27c0e75 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -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" } ] diff --git a/docs/agent-contract.md b/docs/agent-contract.md index efd08f59..c7334b75 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -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 --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. diff --git a/src/commands/spec.ts b/src/commands/spec.ts index e459342d..38d45da4 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -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 : [], })); diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index 4d90e965..bcb605de 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -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 }); } diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index d8ddfc39..7684d266 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -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 diff --git a/src/core/schemas/base.schema.ts b/src/core/schemas/base.schema.ts index a6472ddb..3d9013a4 100644 --- a/src/core/schemas/base.schema.ts +++ b/src/core/schemas/base.schema.ts @@ -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 diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index 42426da9..675e1bbe 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -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 { diff --git a/test/core/parsers/change-parser.test.ts b/test/core/parsers/change-parser.test.ts index 9a901c00..280f7c11 100644 --- a/test/core/parsers/change-parser.test.ts +++ b/test/core/parsers/change-parser.test.ts @@ -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'); + }); + }); }); diff --git a/test/core/parsers/markdown-parser.test.ts b/test/core/parsers/markdown-parser.test.ts index 849e5114..f2ea8d69 100644 --- a/test/core/parsers/markdown-parser.test.ts +++ b/test/core/parsers/markdown-parser.test.ts @@ -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'); + }); + }); });