Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/requirement-length-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

A requirement description over 500 characters is now a warning instead of an informational hint, so `openspec validate --strict` fails on it and CI can enforce the limit. The check also covers ADDED requirements in a change, so `openspec validate <change> --strict` catches a new overlong requirement before archive. Normal validation and archive are unchanged: they still pass when this is the only finding. The specs instruction now explains how to split an existing long requirement without losing its scenarios.
2 changes: 1 addition & 1 deletion docs-lab/reference/schemas/spec-driven/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Format requirements:
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
- Every requirement MUST have at least one scenario.
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions once they reach the main spec. This is an informational hint, not an error. 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.
- 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.

New capabilities only: the delta spec's first section is `## Purpose` -
one or two sentences (50+ characters, or `openspec validate --strict`
Expand Down
2 changes: 1 addition & 1 deletion schemas/spec-driven/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ artifacts:
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
- Every requirement MUST have at least one scenario.
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions once they reach the main spec. This is an informational hint, not an error. 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.
- 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.

New capabilities only: the delta spec's first section is `## Purpose` -
one or two sentences (50+ characters, or `openspec validate --strict`
Expand Down
13 changes: 12 additions & 1 deletion src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ export class Validator {
),
});
}
// Same limit the main spec enforces after archive (#1976), so
// `validate <change> --strict` catches a new overlong requirement
// before it lands. MODIFIED is left alone: its text is the existing
// requirement, which the specs instruction says to keep whole.
if (requirementText && requirementText.length > MAX_REQUIREMENT_TEXT_LENGTH) {
issues.push({
level: 'WARNING',
path: entryPath,
message: `ADDED "${block.name}": ${VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG}`,
});
}
const scenarioCount = this.countScenarios(block.raw);
if (scenarioCount < 1) {
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
Expand Down Expand Up @@ -820,7 +831,7 @@ export class Validator {
spec.requirements.forEach((req, index) => {
if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {
issues.push({
level: 'INFO',
level: 'WARNING',
path: `requirements[${index}]`,
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
});
Expand Down
8 changes: 6 additions & 2 deletions test/core/templates/requirement-length-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ describe('specs instruction requirement length (#1976)', () => {
expect(instruction).toContain(`${MAX_REQUIREMENT_TEXT_LENGTH} characters or fewer`);
expect(instruction).toContain('split a requirement that covers several behaviors');
// Existing requirements under MODIFIED must be copied whole (scenario-loss
// validation rejects a split), and the limit is only an INFO hint.
expect(instruction).toContain('This is an informational hint, not an error.');
// validation rejects a split), and the limit is a warning that fails --strict.
expect(instruction).toContain('`openspec validate --strict` fails on it.');
expect(instruction).toContain('Under MODIFIED, keep the existing requirement block whole');
// Strict CI catches new requirements before archive, and an existing long
// requirement has a split path that keeps every scenario (#1976).
expect(instruction).toContain('flags longer descriptions in ADDED requirements and in the main spec');
expect(instruction).toContain('keep its header and every scenario');
});
});
83 changes: 82 additions & 1 deletion test/core/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,37 @@ ${requirementPrefix}${'x'.repeat(length - requirementPrefix.length)}
expect.objectContaining({ message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG })
);
expect(overLimit.issues).toContainEqual({
level: 'INFO',
level: 'WARNING',
path: 'requirements[0]',
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
});
});

it('fails strict validation, but not normal validation, on an overlong requirement (#1976)', async () => {
const spec = `# Overlong requirement

## Purpose
This specification checks how strict mode treats an overlong requirement description.

## Requirements

### Requirement: Overlong
The system SHALL ${'x'.repeat(MAX_REQUIREMENT_TEXT_LENGTH)}

#### Scenario: Overlong is checked
- **WHEN** the requirement is validated
- **THEN** the length finding is reported`;

const normal = await new Validator().validateSpecContent('overlong', spec);
const strict = await new Validator(true).validateSpecContent('overlong', spec);

expect(normal.valid).toBe(true);
expect(strict.valid).toBe(false);
expect(strict.issues).toEqual([
expect.objectContaining({ level: 'WARNING', message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG }),
]);
});

it('should detect missing overview section', async () => {
const specContent = `# User Authentication Spec

Expand Down Expand Up @@ -483,6 +508,62 @@ Then result`;
});
});

describe('validateChangeDeltaSpecs requirement length (#1976)', () => {
const requirementPrefix = 'The system SHALL ';
const writeDelta = async (name: string, section: 'ADDED' | 'MODIFIED', length: number) => {
const changeDir = path.join(testDir, name);
const specsDir = path.join(changeDir, 'specs', 'test-spec');
await fs.mkdir(specsDir, { recursive: true });
await fs.writeFile(
path.join(specsDir, 'spec.md'),
`## ${section} Requirements

### Requirement: Long
${requirementPrefix}${'x'.repeat(length - requirementPrefix.length)}

#### Scenario: Long is checked
- **WHEN** the change is validated
- **THEN** the length finding is reported`
);
return changeDir;
};

it('fails strict, but not normal, validation on an overlong ADDED requirement', async () => {
const changeDir = await writeDelta('added-long', 'ADDED', MAX_REQUIREMENT_TEXT_LENGTH + 1);

const normal = await new Validator().validateChangeDeltaSpecs(changeDir);
const strict = await new Validator(true).validateChangeDeltaSpecs(changeDir);

expect(normal.valid).toBe(true);
expect(strict.valid).toBe(false);
expect(strict.issues).toEqual([
expect.objectContaining({
level: 'WARNING',
message: `ADDED "Long": ${VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG}`,
}),
]);
});

it('accepts an ADDED requirement at the limit', async () => {
const changeDir = await writeDelta('added-at-limit', 'ADDED', MAX_REQUIREMENT_TEXT_LENGTH);

const strict = await new Validator(true).validateChangeDeltaSpecs(changeDir);

expect(strict.valid).toBe(true);
expect(strict.issues).toEqual([]);
});

it('does not flag an overlong MODIFIED requirement, which keeps the existing text whole', async () => {
const changeDir = await writeDelta('modified-long', 'MODIFIED', MAX_REQUIREMENT_TEXT_LENGTH + 1);

const strict = await new Validator(true).validateChangeDeltaSpecs(changeDir);

expect(strict.issues.map((i) => i.message)).not.toContainEqual(
expect.stringContaining(VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG)
);
});
});

describe('validateChangeDeltaSpecs with metadata', () => {
it('rejects a delta that both renames and removes the same requirement', async () => {
// Parity with archive: apply-time rejects this contradiction, so
Expand Down
Loading