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/allow-non-english-requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

`openspec validate` now treats the English `SHALL`/`MUST` convention as guidance in normal mode, so requirements written in other languages can validate. Strict mode continues to enforce the convention.
29 changes: 28 additions & 1 deletion openspec/specs/cli-validate/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ The validator SHALL recognize bulleted lines that look like scenarios (e.g., lin
- **AND** ...
```

### Requirement: Normative keyword guidance SHALL not require English

The validation report SHALL include a warning for a non-empty requirement body without the literal English keywords `SHALL` or `MUST`. Normal validation SHALL remain valid when that warning is the only issue, while strict validation SHALL remain invalid because strict mode treats warnings as failures.

A requirement with no body content before its scenarios SHALL remain an error.

#### Scenario: Non-English main spec

- **WHEN** a main spec has a non-empty requirement body written without the English keywords `SHALL` or `MUST`
- **THEN** the validation report includes an RFC 2119 guidance warning
- **AND** normal validation succeeds

#### Scenario: Non-English change delta

- **WHEN** an ADDED or MODIFIED requirement has a non-empty body written without the English keywords `SHALL` or `MUST`
- **THEN** the validation report includes an RFC 2119 guidance warning
- **AND** normal validation succeeds

#### Scenario: Strict validation preserves keyword enforcement

- **WHEN** the same main spec or change is validated in strict mode
- **THEN** the warning causes validation to fail

#### Scenario: Requirement body is missing

- **WHEN** a requirement has no body content before its scenarios
- **THEN** validation reports an error

### Requirement: All issues SHALL include file paths and structured locations
Error, warning, and info messages SHALL include:
- Source file path (`openspec/changes/{id}/proposal.md`, `.../specs/{cap}/spec.md`)
Expand Down Expand Up @@ -245,4 +273,3 @@ The markdown parser SHALL correctly identify sections regardless of line ending
- **AND** the document contains `## Why` and `## What Changes`
- **WHEN** running `openspec validate <change-id>`
- **THEN** validation SHALL recognize the sections and NOT raise parsing errors

57 changes: 44 additions & 13 deletions src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export class Validator {
* Validate delta-formatted spec files under a change directory.
* Enforces:
* - At least one delta across all files
* - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario
* - ADDED/MODIFIED: each requirement has at least one scenario; missing
* English SHALL/MUST keywords are guidance unless strict mode is enabled
* - REMOVED: names only; no scenario/description required
* - RENAMED: pairs well-formed
* - No duplicates within sections; no cross-section conflicts per spec
Expand Down Expand Up @@ -247,7 +248,15 @@ export class Validator {
: `ADDED "${block.name}" is missing requirement text`,
});
} else if (!this.containsShallOrMust(requirementText)) {
issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) });
issues.push({
level: 'WARNING',
path: entryPath,
message: this.buildMissingShallOrMustMessage(
`ADDED "${block.name}"`,
block.name,
true
),
});
}
const scenarioCount = this.countScenarios(block.raw);
if (scenarioCount < 1) {
Expand All @@ -274,7 +283,15 @@ export class Validator {
: `MODIFIED "${block.name}" is missing requirement text`,
});
} else if (!this.containsShallOrMust(requirementText)) {
issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) });
issues.push({
level: 'WARNING',
path: entryPath,
message: this.buildMissingShallOrMustMessage(
`MODIFIED "${block.name}"`,
block.name,
true
),
});
}
const scenarioCount = this.countScenarios(block.raw);
if (scenarioCount < 1) {
Expand Down Expand Up @@ -589,21 +606,30 @@ export class Validator {
}
});

// SHALL/MUST body-keyword enforcement for main specs (#1156). The main-spec
// SHALL/MUST body-keyword guidance for main specs (#1156, #243). The main-spec
// parser collapses the requirement header into `text`, so we recover the
// header+body pairs here (the same source the delta path trusts) and reuse
// the delta detection: a body that omits the keyword errors, with the
// targeted "move it to the body line" hint when the keyword is in the header
// only and the generic message otherwise. Emitted exactly once per
// the delta detection. A non-empty body that omits the English keyword gets
// guidance, while a missing body remains an error. Emitted exactly once per
// requirement (the Zod refine that used to emit a generic error is removed).
extractRequirementsSection(content).bodyBlocks.forEach((block, index) => {
const requirementText = this.extractRequirementText(block.raw);
if (!requirementText || !this.containsShallOrMust(requirementText)) {
if (!requirementText) {
issues.push({
level: 'ERROR',
path: `requirements[${index}]`,
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
});
} else if (!this.containsShallOrMust(requirementText)) {
issues.push({
level: 'WARNING',
path: `requirements[${index}]`,
message: this.buildMissingShallOrMustMessage(
`Requirement "${block.name}"`,
block.name,
true
),
});
}
});

Expand Down Expand Up @@ -709,7 +735,7 @@ export class Validator {
}

/**
* Build an error message for a requirement block whose body lacks SHALL/MUST.
* Build a message for a requirement block whose body lacks SHALL/MUST.
*
* When the SHALL/MUST keyword already appears in the requirement header (e.g.
* `### Requirement: The system SHALL ...`) the original generic error
Expand All @@ -718,12 +744,17 @@ export class Validator {
* on the requirement body line (the line right after the header), so we point
* the author at that exact fix when the keyword is found in the header only.
*/
private buildMissingShallOrMustMessage(prefix: string, blockName: string): string {
const base = `${prefix} must contain SHALL or MUST`;
private buildMissingShallOrMustMessage(
prefix: string,
blockName: string,
guidanceOnly = false
): string {
const base = `${prefix} ${guidanceOnly ? 'should' : 'must'} contain SHALL or MUST`;
const suffix = guidanceOnly ? ' (RFC 2119 best practice for English specs)' : '';
if (this.containsShallOrMust(blockName)) {
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`;
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.${suffix}`;
}
return base;
return `${base}${suffix}`;
}

private countScenarios(blockRaw: string): number {
Expand Down
143 changes: 143 additions & 0 deletions test/cli-e2e/validate-international.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { afterAll, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { tmpdir } from 'os';
import { runCLI } from '../helpers/run-cli.js';

const tempRoots: string[] = [];

/** Create a temporary project containing a non-English main spec. */
async function prepareNonEnglishSpec(): Promise<string> {
const projectDir = await fs.mkdtemp(path.join(tmpdir(), 'openspec-i18n-validation-'));
tempRoots.push(projectDir);
const specDir = path.join(projectDir, 'openspec', 'specs', '日志记录');
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(
path.join(specDir, 'spec.md'),
`# 日志记录

## Purpose
记录应用程序中的重要事件,以便团队能够诊断问题、调查故障、审计活动并了解长期的系统行为。

## Requirements

### Requirement: 事件记录
系统必须记录应用程序中的重要事件。

#### Scenario: 事件发生
- **WHEN** 应用程序生成重要事件
- **THEN** 系统保存该事件
`
);
return projectDir;
}

/** Create a temporary project containing a non-English change delta. */
async function prepareNonEnglishChange(): Promise<string> {
const projectDir = await fs.mkdtemp(path.join(tmpdir(), 'openspec-i18n-change-validation-'));
tempRoots.push(projectDir);
const specDir = path.join(
projectDir,
'openspec',
'changes',
'添加日志',
'specs',
'日志记录'
);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(
path.join(specDir, 'spec.md'),
`# 日志记录变更

## ADDED Requirements

### Requirement: 事件记录
系统必须记录应用程序中的重要事件。

#### Scenario: 事件发生
- **WHEN** 应用程序生成重要事件
- **THEN** 系统保存该事件
`
);
return projectDir;
}

afterAll(async () => {
await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));
});

describe('non-English validation (#243)', () => {
it('passes normally with guidance but still fails in strict mode', async () => {
const projectDir = await prepareNonEnglishSpec();

const normal = await runCLI(
['validate', '日志记录', '--type', 'spec', '--no-interactive'],
{ cwd: projectDir }
);
expect(normal.exitCode).toBe(0);
expect(normal.stdout).toContain('Specification');
expect(normal.stdout).toContain('is valid');

const normalJson = await runCLI(
['validate', '日志记录', '--type', 'spec', '--json', '--no-interactive'],
{ cwd: projectDir }
);
const report = JSON.parse(normalJson.stdout);
expect(normalJson.exitCode).toBe(0);
expect(report.summary.totals).toMatchObject({ passed: 1, failed: 0 });
expect(report.items[0].valid).toBe(true);
expect(report.items[0].issues).toContainEqual(
expect.objectContaining({
level: 'WARNING',
message: expect.stringContaining('should contain SHALL or MUST'),
})
);

const strict = await runCLI(
['validate', '日志记录', '--type', 'spec', '--strict', '--no-interactive'],
{ cwd: projectDir }
);
expect(strict.exitCode).toBe(1);
const strictOutput = `${strict.stdout}${strict.stderr}`;
expect(strictOutput).toContain('should contain SHALL or MUST');
expect(strictOutput).toContain('has issues');
});

it('validates a non-English change delta normally but not in strict mode', async () => {
const projectDir = await prepareNonEnglishChange();

const normalJson = await runCLI(
['validate', '添加日志', '--type', 'change', '--json', '--no-interactive'],
{ cwd: projectDir }
);
const normalReport = JSON.parse(normalJson.stdout);
expect(normalJson.exitCode).toBe(0);
expect(normalReport.summary.totals).toMatchObject({ passed: 1, failed: 0 });
expect(normalReport.items[0]).toMatchObject({
id: '添加日志',
type: 'change',
valid: true,
});
expect(normalReport.items[0].issues).toContainEqual(
expect.objectContaining({
level: 'WARNING',
message: expect.stringContaining('should contain SHALL or MUST'),
})
);

const strictJson = await runCLI(
['validate', '添加日志', '--type', 'change', '--strict', '--json', '--no-interactive'],
{ cwd: projectDir }
);
const strictReport = JSON.parse(strictJson.stdout);
expect(strictJson.exitCode).toBe(1);
expect(strictReport.summary.totals).toMatchObject({ passed: 0, failed: 1 });
expect(strictReport.items[0].valid).toBe(false);
expect(strictReport.items[0].issues).toContainEqual(
expect.objectContaining({
level: 'WARNING',
message: expect.stringContaining('should contain SHALL or MUST'),
})
);
});
});
4 changes: 1 addition & 3 deletions test/core/archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3321,15 +3321,13 @@ The system SHALL log all events.`;
const changeSpecDir = path.join(changeDir, 'specs', 'bad-capability');
await fs.mkdir(changeSpecDir, { recursive: true });

// Delta spec missing required SHALL/MUST keyword -> validation error
// Delta spec missing requirement text -> validation error
const specContent = `# Bad Capability - Changes

## ADDED Requirements

### Requirement: Logging Feature

The system will log all events.

#### Scenario: Event recorded
- **WHEN** an event occurs
- **THEN** it is captured`;
Expand Down
Loading
Loading