From 71fc447aa21aaae7449c27a0d37cd36570aabd79 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 21 Jul 2026 12:53:14 -0500 Subject: [PATCH 1/3] fix(archive): stop reporting phantom proposal warnings from delta specs `openspec validate --strict` reported a change as valid while `openspec archive` printed "Proposal warnings in proposal.md" for the same change, blaming requirements that do not exist. Archive validates the proposal with `validateChange`, which parses the change together with its delta specs. Requirement-level issues from those deltas were printed in the proposal block even though they are not proposal issues. Two problems followed: - The change parser records every requirement under both `requirement` and `requirements`, so each defect was printed twice, then a third time by the delta report. - A heading inside a delta section that is not a `### Requirement:` heading was parsed as a requirement, producing a scenario warning against a requirement that does not exist. The delta reader already handles this correctly and reports it as an informational note. Proposal warnings now report proposal-level issues only. Delta spec issues keep being reported once, by the delta report, with the capability file path and requirement name. Exit codes are unchanged: this block was already non-blocking, and blocking delta validation is untouched. Refs #498 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../archive-phantom-proposal-warnings.md | 5 + openspec/specs/cli-archive/spec.md | 9 ++ src/core/archive.ts | 18 ++- test/core/archive.test.ts | 123 ++++++++++++++++++ 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 .changeset/archive-phantom-proposal-warnings.md diff --git a/.changeset/archive-phantom-proposal-warnings.md b/.changeset/archive-phantom-proposal-warnings.md new file mode 100644 index 000000000..f42e45390 --- /dev/null +++ b/.changeset/archive-phantom-proposal-warnings.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Fix `openspec archive` reporting phantom proposal warnings that `openspec validate` never showed. Requirement-level issues coming from the delta specs are no longer repeated in the "Proposal warnings in proposal.md" block, so a heading such as `### Documentation Requirements` inside a delta section no longer produces a scenario warning against a requirement that does not exist. Genuine proposal-level warnings, and blocking delta spec errors, are unchanged. diff --git a/openspec/specs/cli-archive/spec.md b/openspec/specs/cli-archive/spec.md index da1d6404a..599d32705 100644 --- a/openspec/specs/cli-archive/spec.md +++ b/openspec/specs/cli-archive/spec.md @@ -193,6 +193,15 @@ The archive command SHALL validate changes before applying them to ensure data i - **AND** only proceed if validation passes - **AND** show validation errors if it fails +#### Scenario: Proposal warnings stay proposal-level + +- **WHEN** archiving a change whose delta specs contain a heading that is not a + `### Requirement:` heading +- **THEN** the non-blocking proposal warnings SHALL report proposal-level issues only +- **AND** SHALL NOT report requirement-level issues reached through the delta specs +- **AND** delta spec issues SHALL be reported once by the delta spec validation, + with the capability file path and requirement name + #### Scenario: Force archive without validation - **WHEN** executing `openspec archive change-name --no-validate` diff --git a/src/core/archive.ts b/src/core/archive.ts index 869db1814..773a07c92 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -259,10 +259,22 @@ export class ArchiveCommand { try { await fs.access(changeFile); const changeReport = await validator.validateChange(changeFile); - // Proposal validation is informative only (do not block archive) - if (!changeReport.valid) { + // Proposal validation is informative only (do not block archive). + // Report proposal-level issues only. Requirement-level issues reached + // through `deltas..requirement(s)` belong to the delta specs, and + // the delta report below already reports them once each, with the + // capability file path and requirement name. Repeating them here was + // noisy and misleading (#498): the change parser records every + // requirement under both `requirement` and `requirements`, so each + // defect appeared twice, and a stray non-`### Requirement:` header in + // a delta section surfaced as a phantom scenario warning against a + // requirement that does not exist. + const proposalIssues = changeReport.issues.filter( + (issue) => !/^deltas\.\d+\.requirements?\./.test(issue.path) + ); + if (!changeReport.valid && proposalIssues.length > 0) { console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`)); - for (const issue of changeReport.issues) { + for (const issue of proposalIssues) { const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ'); console.log(chalk.yellow(` ${symbol} ${issue.message}`)); } diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 975970ca2..69067c812 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -1548,4 +1548,127 @@ The system SHALL do the thing differently. await expect(fs.access(changeDir)).resolves.not.toThrow(); }); }); + + describe('proposal warnings (#498)', () => { + const LONG_WHY = + 'This change exists to document AI application patterns thoroughly for the team, which is long enough.'; + + async function createChange( + changeName: string, + why: string, + deltaSpec: string + ): Promise { + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'specs', 'docs'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + `# Proposal\n\n## Why\n${why}\n\n## What Changes\n- Add docs.\n` + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile(path.join(changeDir, 'specs', 'docs', 'spec.md'), deltaSpec); + return changeDir; + } + + function loggedLines(): string[] { + return (console.log as unknown as ReturnType).mock.calls.map( + (call) => String(call[0]) + ); + } + + // A stray non-`### Requirement:` header inside a delta section used to be + // parsed as a requirement, so archive blamed a requirement that does not + // exist while `openspec validate` reported the change as valid (#498). + it('does not report phantom requirement warnings for a stray delta header', async () => { + const changeName = 'stray-header'; + await createChange( + changeName, + LONG_WHY, + [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Documentation Requirements', + '', + '### Requirement: AI Application Documentation', + 'Teams building AI applications SHALL document agent definitions.', + '', + '#### Scenario: Agent Definition Documentation', + '- **WHEN** a team ships an agent', + '- **THEN** the agent definition is documented', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).not.toContain('Proposal warnings in proposal.md'); + expect(output).not.toContain('Requirement must have at least one scenario'); + + // The change still archives, exactly as `validate` predicted. + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives).toEqual([expect.stringMatching(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`))]); + }); + + it('still reports genuine proposal-level warnings', async () => { + const changeName = 'short-why'; + await createChange( + changeName, + 'Short.', + [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Requirement: Real Requirement', + 'The system SHALL do a thing.', + '', + '#### Scenario: It works', + '- **WHEN** invoked', + '- **THEN** it works', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).toContain('Proposal warnings in proposal.md'); + expect(output).toContain('Why section must be at least 50 characters'); + }); + + // Real delta defects are still caught, and now reported once by the delta + // report instead of three times (twice as proposal warnings, once here). + it('still blocks the archive on real delta requirement errors, reported once', async () => { + const changeName = 'bad-delta'; + const changeDir = await createChange( + changeName, + LONG_WHY, + [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Requirement: Missing Scenario', + 'The system SHALL do a thing.', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const lines = loggedLines(); + const output = lines.join('\n'); + expect(output).toContain('Validation errors in change delta specs'); + expect(output).toContain('must include at least one scenario'); + expect(output).not.toContain('Proposal warnings in proposal.md'); + expect( + lines.filter((line) => line.includes('must include at least one scenario')) + ).toHaveLength(1); + + // The change was not archived. + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + }); }); From e34138ec1012e4b08845b0d996794a228081cffe Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 21 Jul 2026 13:14:54 -0500 Subject: [PATCH 2/3] fix(archive): correct proposal-warning claims and pin bracket-path rules Review follow-ups, no behavior change: - The delta report prints only the issue message, never `issue.path`, so it does not name the capability file. Drop that claim from the spec scenario and the code comment; two capabilities with the same defect print two identical lines. - Only the missing-scenario class was reported three times. Say that precisely instead of generalizing to every delta error. - Widen the spec scenario: the filter applies to every archive, not only to changes carrying a stray heading. - Add a test pinning that applyChangeRules bracket paths (`deltas[].description`) survive the dot-anchored filter, so a future path normalization cannot silently widen it. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/specs/cli-archive/spec.md | 9 ++++----- src/core/archive.ts | 14 +++++++------- test/core/archive.test.ts | 27 +++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/openspec/specs/cli-archive/spec.md b/openspec/specs/cli-archive/spec.md index 599d32705..900bf9a97 100644 --- a/openspec/specs/cli-archive/spec.md +++ b/openspec/specs/cli-archive/spec.md @@ -195,12 +195,11 @@ The archive command SHALL validate changes before applying them to ensure data i #### Scenario: Proposal warnings stay proposal-level -- **WHEN** archiving a change whose delta specs contain a heading that is not a - `### Requirement:` heading +- **WHEN** archiving a change - **THEN** the non-blocking proposal warnings SHALL report proposal-level issues only -- **AND** SHALL NOT report requirement-level issues reached through the delta specs -- **AND** delta spec issues SHALL be reported once by the delta spec validation, - with the capability file path and requirement name +- **AND** SHALL NOT repeat requirement-level issues reached through the delta specs +- **AND** requirement-level issues in delta specs SHALL be left to delta spec + validation, which blocks the archive when they are errors #### Scenario: Force archive without validation diff --git a/src/core/archive.ts b/src/core/archive.ts index 773a07c92..d159a9077 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -262,13 +262,13 @@ export class ArchiveCommand { // Proposal validation is informative only (do not block archive). // Report proposal-level issues only. Requirement-level issues reached // through `deltas..requirement(s)` belong to the delta specs, and - // the delta report below already reports them once each, with the - // capability file path and requirement name. Repeating them here was - // noisy and misleading (#498): the change parser records every - // requirement under both `requirement` and `requirements`, so each - // defect appeared twice, and a stray non-`### Requirement:` header in - // a delta section surfaced as a phantom scenario warning against a - // requirement that does not exist. + // the delta report below already reports them, naming the delta + // operation and requirement. Repeating them here was noisy and + // misleading (#498): the change parser records every requirement + // under both `requirement` and `requirements`, so a missing scenario + // was reported twice here and once by the delta report, and a stray + // non-`### Requirement:` header in a delta section surfaced as a + // phantom scenario warning against a requirement that does not exist. const proposalIssues = changeReport.issues.filter( (issue) => !/^deltas\.\d+\.requirements?\./.test(issue.path) ); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 69067c812..81e6d619d 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -1638,8 +1638,31 @@ The system SHALL do the thing differently. expect(output).toContain('Why section must be at least 50 characters'); }); - // Real delta defects are still caught, and now reported once by the delta - // report instead of three times (twice as proposal warnings, once here). + // The filter is anchored to the dot-joined Zod paths + // (`deltas..requirement(s).…`). Rules in applyChangeRules use bracket + // notation (`deltas[].description`) and describe simple deltas parsed + // from `## What Changes`, which are proposal-level. They must survive. + it('keeps proposal-level warnings about simple deltas from What Changes', async () => { + const changeName = 'simple-deltas'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Proposal\n\n## Why\nShort.\n\n## What Changes\n- **docs:** add x\n' + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).toContain('Proposal warnings in proposal.md'); + expect(output).toContain('Delta description is too brief'); + expect(output).toContain('ADDED Delta should include requirements'); + }); + + // Real delta defects are still caught. A missing scenario used to be + // reported three times (twice as proposal warnings, once by the delta + // report) and is now reported once, by the delta report. it('still blocks the archive on real delta requirement errors, reported once', async () => { const changeName = 'bad-delta'; const changeDir = await createChange( From 8f25d8d0bb66e628ed2c7b7b4eca6e089bb1423f Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 21 Jul 2026 13:47:33 -0500 Subject: [PATCH 3/3] fix(parser): ignore delta headers that are not "### Requirement:" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the cause of #498 rather than one of its symptoms. A header inside a delta section that is not a `### Requirement:` header — a divider such as `### Documentation Requirements` — was read as a requirement with no scenario. That invented a delta that does not exist: `openspec archive` warned about a missing scenario, and `openspec show --json` and `openspec change list` counted it. ChangeParser now filters those headers before reading requirements, matching REQUIREMENT_HEADER_REGEX, which the delta reader already uses. The override lives in ChangeParser, so main spec parsing — view, list, spec --json, spec validation — is untouched. The archive filter stays: it covers the half the parser cannot. The change parser records every requirement under both `requirement` and `requirements`, so each delta defect was printed twice, and REMOVED requirements are names-only by design yet were reported as missing a scenario on every correct removal. Also from review: - Soften the spec scenario; delta spec validation does not always run (the hasDeltaSpecs gate is case-sensitive), so it cannot be promised as the reporter. - Assert VALIDATION_MESSAGES constants instead of message literals. - Add parser-level and REMOVED-only regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../archive-phantom-proposal-warnings.md | 6 +- openspec/specs/cli-archive/spec.md | 9 +-- src/core/archive.ts | 19 ++--- src/core/parsers/change-parser.ts | 23 +++++++ test/core/archive.test.ts | 36 +++++++++- test/core/parsers/change-parser.test.ts | 69 +++++++++++++++++++ 6 files changed, 146 insertions(+), 16 deletions(-) diff --git a/.changeset/archive-phantom-proposal-warnings.md b/.changeset/archive-phantom-proposal-warnings.md index f42e45390..1b232f361 100644 --- a/.changeset/archive-phantom-proposal-warnings.md +++ b/.changeset/archive-phantom-proposal-warnings.md @@ -2,4 +2,8 @@ "@fission-ai/openspec": patch --- -Fix `openspec archive` reporting phantom proposal warnings that `openspec validate` never showed. Requirement-level issues coming from the delta specs are no longer repeated in the "Proposal warnings in proposal.md" block, so a heading such as `### Documentation Requirements` inside a delta section no longer produces a scenario warning against a requirement that does not exist. Genuine proposal-level warnings, and blocking delta spec errors, are unchanged. +Fix phantom requirements parsed from delta specs, which made `openspec archive` warn about problems `openspec validate` never reported. + +A header inside a delta section that is not a `### Requirement:` header — a divider such as `### Documentation Requirements` — was read as a requirement with no scenario. `openspec archive` warned that it was missing a scenario, and `openspec show --json` and `openspec change list` counted it as an extra delta. The change parser now ignores those headers, matching the delta reader, so the phantom is gone from the warnings and from the JSON. Main spec parsing is unchanged. + +`openspec archive` also no longer repeats requirement-level issues from the delta specs in its non-blocking "Proposal warnings in proposal.md" block. Each defect was printed twice there, and a `## REMOVED Requirements` entry — names-only by design — was reported as missing a scenario on every correct removal. Delta spec validation still reports and blocks on genuine defects, and proposal-level warnings are unchanged. diff --git a/openspec/specs/cli-archive/spec.md b/openspec/specs/cli-archive/spec.md index 900bf9a97..f5f12ccfe 100644 --- a/openspec/specs/cli-archive/spec.md +++ b/openspec/specs/cli-archive/spec.md @@ -196,10 +196,11 @@ The archive command SHALL validate changes before applying them to ensure data i #### Scenario: Proposal warnings stay proposal-level - **WHEN** archiving a change -- **THEN** the non-blocking proposal warnings SHALL report proposal-level issues only -- **AND** SHALL NOT repeat requirement-level issues reached through the delta specs -- **AND** requirement-level issues in delta specs SHALL be left to delta spec - validation, which blocks the archive when they are errors +- **THEN** the non-blocking proposal warnings SHALL NOT repeat requirement-level + issues reached through the delta specs +- **AND** a requirement removed by a `## REMOVED Requirements` delta SHALL NOT be + reported as missing a scenario +- **AND** proposal-level issues SHALL still be reported #### Scenario: Force archive without validation diff --git a/src/core/archive.ts b/src/core/archive.ts index d159a9077..9e861070a 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -260,15 +260,16 @@ export class ArchiveCommand { await fs.access(changeFile); const changeReport = await validator.validateChange(changeFile); // Proposal validation is informative only (do not block archive). - // Report proposal-level issues only. Requirement-level issues reached - // through `deltas..requirement(s)` belong to the delta specs, and - // the delta report below already reports them, naming the delta - // operation and requirement. Repeating them here was noisy and - // misleading (#498): the change parser records every requirement - // under both `requirement` and `requirements`, so a missing scenario - // was reported twice here and once by the delta report, and a stray - // non-`### Requirement:` header in a delta section surfaced as a - // phantom scenario warning against a requirement that does not exist. + // `validateChange` parses the change together with its delta specs, + // so it also raises requirement-level issues under + // `deltas..requirement(s)`. Those + // are not proposal problems, and reporting them here was noisy and + // sometimes wrong (#498): the change parser records every requirement + // under both `requirement` and `requirements`, so each defect was + // printed twice, and REMOVED requirements — names-only by design — + // produced a "missing scenario" warning for a correct removal. + // Genuine delta defects are still caught below, by the delta spec + // validation and by the rebuilt-spec check that runs before any write. const proposalIssues = changeReport.issues.filter( (issue) => !/^deltas\.\d+\.requirements?\./.test(issue.path) ); diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index b6eb42017..134d32085 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -75,6 +75,29 @@ export class ChangeParser extends MarkdownParser { return deltas; } + /** + * Read requirements from a delta section, ignoring headers that are not + * `### Requirement: `. + * + * A delta section often carries divider headers such as + * `### Documentation Requirements`. The base parser treats every child header + * as a requirement, which invented a scenario-less requirement that does not + * exist (#498): archive warned about a missing scenario, and `show --json` + * reported an extra delta. The delta reader already skips these headers and + * notes them, so this keeps the two readers in agreement. + * + * Overriding here rather than in MarkdownParser keeps main spec parsing — + * `view`, `list`, `spec --json`, spec validation — untouched. + */ + protected parseRequirements(section: Section): Requirement[] { + return super.parseRequirements({ + ...section, + children: section.children.filter((child) => + /^Requirement:\s*\S/i.test(child.title.trim()) + ), + }); + } + private parseSpecDeltas(specName: string, content: string): Delta[] { const deltas: Delta[] = []; const sections = this.parseSectionsFromContent(content); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 81e6d619d..62feee7a6 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ArchiveCommand } from '../../src/core/archive.js'; import { Validator } from '../../src/core/validation/validator.js'; +import { VALIDATION_MESSAGES } from '../../src/core/validation/constants.js'; import { formatLocalDate } from '../../src/utils/date.js'; import { promises as fs } from 'fs'; import path from 'path'; @@ -1611,6 +1612,37 @@ The system SHALL do the thing differently. expect(archives).toEqual([expect.stringMatching(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`))]); }); + // REMOVED requirements are names-only by design, so delta spec validation + // exempts them. The proposal report did not, and warned about a missing + // scenario on every correct removal. + it('does not warn about missing scenarios for REMOVED requirements', async () => { + const changeName = 'removal'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'specs', 'docs'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + `# Proposal\n\n## Why\n${LONG_WHY}\n\n## What Changes\n- Remove docs.\n` + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile( + path.join(changeDir, 'specs', 'docs', 'spec.md'), + '# Docs Delta\n\n## REMOVED Requirements\n\n### Requirement: Old Thing\n' + ); + // The removal needs a main spec to remove the requirement from. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'docs'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + '# docs Specification\n\n## Purpose\nDocs.\n\n## Requirements\n### Requirement: Old Thing\nThe system SHALL do the old thing.\n\n#### Scenario: Old\n- **WHEN** invoked\n- **THEN** it happens\n' + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).not.toContain('Proposal warnings in proposal.md'); + expect(output).not.toContain('Requirement must have at least one scenario'); + }); + it('still reports genuine proposal-level warnings', async () => { const changeName = 'short-why'; await createChange( @@ -1656,8 +1688,8 @@ The system SHALL do the thing differently. const output = loggedLines().join('\n'); expect(output).toContain('Proposal warnings in proposal.md'); - expect(output).toContain('Delta description is too brief'); - expect(output).toContain('ADDED Delta should include requirements'); + expect(output).toContain(VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF); + expect(output).toContain(`ADDED ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`); }); // Real delta defects are still caught. A missing scenario used to be diff --git a/test/core/parsers/change-parser.test.ts b/test/core/parsers/change-parser.test.ts index 0a9e1bb50..9a901c000 100644 --- a/test/core/parsers/change-parser.test.ts +++ b/test/core/parsers/change-parser.test.ts @@ -69,4 +69,73 @@ describe('ChangeParser', () => { expect(change.deltas[0].operation).toBe('ADDED'); }); }); + + // A divider header inside a delta section used to be parsed as a requirement, + // inventing a scenario-less delta that does not exist (#498). + it('ignores delta headers that are not "### Requirement:" (#498)', async () => { + await withTempDir(async (dir) => { + const specDir = path.join(dir, 'specs', 'docs'); + await fs.mkdir(specDir, { recursive: true }); + + const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- Add docs`; + const deltaSpec = [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Documentation Requirements', + '', + '### Requirement: AI Application Documentation', + 'Teams building AI applications SHALL document agent definitions.', + '', + '#### Scenario: Agent Definition Documentation', + '- **WHEN** a team ships an agent', + '- **THEN** the agent definition is documented', + ].join('\n'); + + await fs.writeFile(path.join(specDir, 'spec.md'), deltaSpec, 'utf8'); + + const parser = new ChangeParser(content, dir); + const change = await parser.parseChangeWithDeltas('test-change'); + + expect(change.deltas.length).toBe(1); + expect(change.deltas[0].requirement?.text).toBe( + 'Teams building AI applications SHALL document agent definitions.' + ); + expect(change.deltas[0].requirement?.scenarios.length).toBe(1); + }); + }); + + // A nameless "### Requirement:" header carries no requirement to validate, + // and the delta reader skips it too. + it('ignores a nameless "### Requirement:" delta header (#498)', async () => { + await withTempDir(async (dir) => { + const specDir = path.join(dir, 'specs', 'docs'); + await fs.mkdir(specDir, { recursive: true }); + + const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- Add docs`; + const deltaSpec = [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Requirement:', + '', + '### Requirement: Real One', + 'The system SHALL do a thing.', + '', + '#### Scenario: It works', + '- **WHEN** invoked', + '- **THEN** it works', + ].join('\n'); + + await fs.writeFile(path.join(specDir, 'spec.md'), deltaSpec, 'utf8'); + + const parser = new ChangeParser(content, dir); + const change = await parser.parseChangeWithDeltas('test-change'); + + expect(change.deltas.length).toBe(1); + expect(change.deltas[0].requirement?.text).toBe('The system SHALL do a thing.'); + }); + }); });