diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index a553fd57b6..85af2ad2e5 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -19,8 +19,8 @@ export class ChangeParser extends MarkdownParser { async parseChangeWithDeltas(name: string): Promise { const sections = this.parseSections(); - const why = this.findSection(sections, 'Why')?.content || ''; - const whatChanges = this.findSection(sections, 'What Changes')?.content || ''; + const why = this.findSection(sections, 'Why')?.content || this.findSection(sections, '为什么')?.content || ''; + const whatChanges = this.findSection(sections, 'What Changes')?.content || this.findSection(sections, '变更内容')?.content || ''; if (!why) { throw new Error('Change 必须包含 Why 章节'); @@ -86,7 +86,7 @@ export class ChangeParser extends MarkdownParser { const sections = this.parseSectionsFromContent(content); // Parse ADDED requirements - const addedSection = this.findSection(sections, 'ADDED Requirements'); + const addedSection = this.findSection(sections, 'ADDED Requirements') || this.findSection(sections, '新增需求'); if (addedSection) { const requirements = this.parseRequirements(addedSection); requirements.forEach(req => { @@ -102,7 +102,7 @@ export class ChangeParser extends MarkdownParser { } // Parse MODIFIED requirements - const modifiedSection = this.findSection(sections, 'MODIFIED Requirements'); + const modifiedSection = this.findSection(sections, 'MODIFIED Requirements') || this.findSection(sections, '修改需求'); if (modifiedSection) { const requirements = this.parseRequirements(modifiedSection); requirements.forEach(req => { @@ -117,7 +117,7 @@ export class ChangeParser extends MarkdownParser { } // Parse REMOVED requirements - const removedSection = this.findSection(sections, 'REMOVED Requirements'); + const removedSection = this.findSection(sections, 'REMOVED Requirements') || this.findSection(sections, '移除需求'); if (removedSection) { const requirements = this.parseRequirements(removedSection); requirements.forEach(req => { @@ -132,7 +132,7 @@ export class ChangeParser extends MarkdownParser { } // Parse RENAMED requirements - const renamedSection = this.findSection(sections, 'RENAMED Requirements'); + const renamedSection = this.findSection(sections, 'RENAMED Requirements') || this.findSection(sections, '重命名需求'); if (renamedSection) { const renames = this.parseRenames(renamedSection.content); renames.forEach(rename => { @@ -153,10 +153,15 @@ export class ChangeParser extends MarkdownParser { const lines = ChangeParser.normalizeContent(content).split('\n'); let currentRename: { from?: string; to?: string } = {}; - + // Match both English ("Requirement") and Chinese ("需求") headers in FROM:/TO: lines. + const REQUIREMENT_KEYWORD_PATTERN = '(?:Requirement|需求)'; + const REQUIREMENT_COLON_PATTERN = '[::]'; + const fromRegex = new RegExp(`^\\s*-?\\s*FROM:\\s*\`?###\\s*${REQUIREMENT_KEYWORD_PATTERN}${REQUIREMENT_COLON_PATTERN}\\s*(.+?)\`?\\s*$`); + const toRegex = new RegExp(`^\\s*-?\\s*TO:\\s*\`?###\\s*${REQUIREMENT_KEYWORD_PATTERN}${REQUIREMENT_COLON_PATTERN}\\s*(.+?)\`?\\s*$`); + for (const line of lines) { - const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); - const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); + const fromMatch = line.match(fromRegex); + const toMatch = line.match(toRegex); if (fromMatch) { currentRename.from = fromMatch[1].trim(); diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index 63a951cc1e..dd6e4d8492 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -73,9 +73,9 @@ export class MarkdownParser { parseSpec(name: string): Spec { const sections = this.parseSections(); - const purpose = this.findSection(sections, 'Purpose')?.content || ''; - - const requirementsSection = this.findSection(sections, 'Requirements'); + const purpose = this.findSection(sections, 'Purpose')?.content || this.findSection(sections, '目的')?.content || ''; + + const requirementsSection = this.findSection(sections, 'Requirements') || this.findSection(sections, '需求'); if (!purpose) { throw new Error('Spec 必须包含 Purpose 章节'); @@ -100,9 +100,9 @@ export class MarkdownParser { parseChange(name: string): Change { const sections = this.parseSections(); - const why = this.findSection(sections, 'Why')?.content || ''; - const whatChanges = this.findSection(sections, 'What Changes')?.content || ''; - + const why = this.findSection(sections, 'Why')?.content || this.findSection(sections, '为什么')?.content || ''; + const whatChanges = this.findSection(sections, 'What Changes')?.content || this.findSection(sections, '变更内容')?.content || ''; + if (!why) { throw new Error('Change 必须包含 Why 章节'); } @@ -269,11 +269,15 @@ export class MarkdownParser { // Use word boundaries to avoid false matches (e.g., "address" matching "add") // Check RENAMED first since it's more specific than patterns containing "new" - if (/\brename(s|d|ing)?\b/.test(lowerDesc) || /\brenamed\s+(to|from)\b/.test(lowerDesc)) { + // English keyword detection uses word boundaries; Chinese keywords are + // matched against the raw description (CJK has no letter-case / \b). + // Note: 1.4.1 matched a bare `新`, which mis-classified 更新/刷新/最新 + // ("update"/"refresh"/"latest") as ADDED; we narrow it to 新增/新建. + if (/\brename(s|d|ing)?\b/.test(lowerDesc) || /\brenamed\s+(to|from)\b/.test(lowerDesc) || /重命名/.test(description)) { operation = 'RENAMED'; - } else if (/\badd(s|ed|ing)?\b/.test(lowerDesc) || /\bcreate(s|d|ing)?\b/.test(lowerDesc) || /\bnew\b/.test(lowerDesc)) { + } else if (/\badd(s|ed|ing)?\b/.test(lowerDesc) || /\bcreate(s|d|ing)?\b/.test(lowerDesc) || /\bnew\b/.test(lowerDesc) || /新增|添加|创建|新建/.test(description)) { operation = 'ADDED'; - } else if (/\bremove(s|d|ing)?\b/.test(lowerDesc) || /\bdelete(s|d|ing)?\b/.test(lowerDesc)) { + } else if (/\bremove(s|d|ing)?\b/.test(lowerDesc) || /\bdelete(s|d|ing)?\b/.test(lowerDesc) || /移除|删除/.test(description)) { operation = 'REMOVED'; } diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index afc55f8914..483b9eb034 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -16,7 +16,12 @@ export function normalizeRequirementName(name: string): string { return name.trim(); } -const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i; +// Match both English ("Requirement") and Chinese ("需求") requirement headers, +// and both the ASCII colon (:) and the full-width Chinese colon (:). +const REQUIREMENT_KEYWORD_PATTERN = '(?:Requirement|需求)'; +const REQUIREMENT_COLON_PATTERN = '[::]'; +const REQUIREMENT_HEADER_REGEX = new RegExp(`^###\\s*${REQUIREMENT_KEYWORD_PATTERN}${REQUIREMENT_COLON_PATTERN}\\s*(.+)\\s*$`, 'i'); +const REQUIREMENT_HEADER_PREFIX = new RegExp(`^###\\s*${REQUIREMENT_KEYWORD_PATTERN}${REQUIREMENT_COLON_PATTERN}`, 'i'); /** * Extracts the Requirements section from a spec file and parses requirement blocks. @@ -24,7 +29,7 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i; export function extractRequirementsSection(content: string): RequirementsSectionParts { const normalized = normalizeLineEndings(content); const lines = normalized.split('\n'); - const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l)); + const reqHeaderIndex = lines.findIndex(l => /^##\s+(?:Requirements|需求)\s*$/i.test(l)); if (reqHeaderIndex === -1) { // No requirements section; create an empty one at the end @@ -58,7 +63,7 @@ export function extractRequirementsSection(content: string): RequirementsSection let preambleLines: string[] = []; // Collect preamble lines until first requirement header - while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_PREFIX.test(sectionBodyLines[cursor])) { preambleLines.push(sectionBodyLines[cursor]); cursor++; } @@ -76,7 +81,7 @@ export function extractRequirementsSection(content: string): RequirementsSection cursor++; // Gather lines until next requirement header or end of section const bodyLines: string[] = [headerLineCandidate]; - while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_PREFIX.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { bodyLines.push(sectionBodyLines[cursor]); cursor++; } @@ -119,6 +124,8 @@ function normalizeLineEndings(content: string): string { export function parseDeltaSpec(content: string): DeltaPlan { const normalized = normalizeLineEndings(content); const sections = splitTopLevelSections(normalized); + // English keys are passed here; getSectionCaseInsensitive also accepts the + // Chinese equivalents (新增需求 / 修改需求 / 移除需求 / 重命名需求). const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements'); const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements'); const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements'); @@ -163,8 +170,19 @@ function splitTopLevelSections(content: string): Record { function getSectionCaseInsensitive(sections: Record, desired: string): { body: string; found: boolean } { const target = desired.toLowerCase(); + // Accept Chinese delta section titles as equivalents of the English headers. + const chineseAlternates: Record = { + 'added requirements': '新增需求', + 'modified requirements': '修改需求', + 'removed requirements': '移除需求', + 'renamed requirements': '重命名需求', + }; + const altTarget = chineseAlternates[target]?.toLowerCase(); for (const [title, body] of Object.entries(sections)) { - if (title.toLowerCase() === target) return { body, found: true }; + const lowerTitle = title.toLowerCase(); + if (lowerTitle === target || (altTarget && lowerTitle === altTarget)) { + return { body, found: true }; + } } return { body: '', found: false }; } @@ -176,7 +194,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc let i = 0; while (i < lines.length) { // Seek next requirement header - while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i])) i++; + while (i < lines.length && !REQUIREMENT_HEADER_PREFIX.test(lines[i])) i++; if (i >= lines.length) break; const headerLine = lines[i]; const m = headerLine.match(REQUIREMENT_HEADER_REGEX); @@ -184,7 +202,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc const name = normalizeRequirementName(m[1]); const buf: string[] = [headerLine]; i++; - while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i]) && !/^##\s+/.test(lines[i])) { + while (i < lines.length && !REQUIREMENT_HEADER_PREFIX.test(lines[i]) && !/^##\s+/.test(lines[i])) { buf.push(lines[i]); i++; } @@ -204,7 +222,9 @@ function parseRemovedNames(sectionBody: string): string[] { continue; } // Also support bullet list of headers - const bullet = line.match(/^\s*-\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); + const bullet = line.match(new RegExp( + '^\\s*-\\s*`?###\\s*' + REQUIREMENT_KEYWORD_PATTERN + REQUIREMENT_COLON_PATTERN + '\\s*(.+?)`?\\s*$' + )); if (bullet) { names.push(normalizeRequirementName(bullet[1])); } @@ -217,9 +237,15 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin const pairs: Array<{ from: string; to: string }> = []; const lines = normalizeLineEndings(sectionBody).split('\n'); let current: { from?: string; to?: string } = {}; + const fromRegex = new RegExp( + '^\\s*-?\\s*FROM:\\s*`?###\\s*' + REQUIREMENT_KEYWORD_PATTERN + REQUIREMENT_COLON_PATTERN + '\\s*(.+?)`?\\s*$' + ); + const toRegex = new RegExp( + '^\\s*-?\\s*TO:\\s*`?###\\s*' + REQUIREMENT_KEYWORD_PATTERN + REQUIREMENT_COLON_PATTERN + '\\s*(.+?)`?\\s*$' + ); for (const line of lines) { - const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); - const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); + const fromMatch = line.match(fromRegex); + const toMatch = line.match(toRegex); if (fromMatch) { current.from = normalizeRequirementName(fromMatch[1]); } else if (toMatch) { diff --git a/src/core/schemas/base.schema.ts b/src/core/schemas/base.schema.ts index 548ef35e56..7d02e821b2 100644 --- a/src/core/schemas/base.schema.ts +++ b/src/core/schemas/base.schema.ts @@ -9,7 +9,7 @@ export const RequirementSchema = z.object({ text: z.string() .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY) .refine( - (text) => text.includes('SHALL') || text.includes('MUST'), + (text) => text.includes('SHALL') || text.includes('MUST') || text.includes('必须') || text.includes('禁止'), VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL ), scenarios: z.array(ScenarioSchema) diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index f62acf1bd2..62c43ff961 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -288,7 +288,7 @@ export async function buildUpdatedSpec( throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`); } // Replace block with provided raw (ensure header line matches key) - const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/i); + const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*(?:Requirement|需求)[::]\s*(.+)\s*$/i); if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) { throw new Error( `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content` diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index b2dca9a1ca..2bf8dd972e 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -441,7 +441,7 @@ export class Validator { } private containsShallOrMust(text: string): boolean { - return /\b(SHALL|MUST)\b/.test(text); + return /\b(SHALL|MUST)\b/.test(text) || /必须|禁止/.test(text); } /** diff --git a/test/core/parsers/chinese-bilingual.test.ts b/test/core/parsers/chinese-bilingual.test.ts new file mode 100644 index 0000000000..cc3c55a98c --- /dev/null +++ b/test/core/parsers/chinese-bilingual.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { MarkdownParser } from '../../../src/core/parsers/markdown-parser.js'; +import { ChangeParser } from '../../../src/core/parsers/change-parser.js'; +import { extractRequirementsSection, parseDeltaSpec } from '../../../src/core/parsers/requirement-blocks.js'; +import { RequirementSchema } from '../../../src/core/schemas/index.js'; +import { Validator } from '../../../src/core/validation/validator.js'; + +/** + * Regression tests for upstream issue studyzy/OpenSpec-cn#31: + * merging v1.5.0 replaced the bilingual (Chinese + English) parsing/matching + * logic that v1.4.1 had with English-only literals, so Chinese-titled specs and + * changes could no longer be parsed, validated, or archived. These tests lock in + * the restored bilingual behaviour while keeping English behaviour intact. + */ +describe('Chinese bilingual parsing (issue #31 regression)', () => { + describe('MarkdownParser.parseSpec', () => { + it('parses a spec written with Chinese section headers (目的 / 需求)', () => { + const content = `# 用户认证规范 + +## 目的 +本规范定义用户认证的需求。 + +## 需求 + +### 需求: 系统必须提供安全的用户认证 +系统必须提供安全的用户登录能力。 + +#### 场景: 成功登录 +- **当** 用户提供有效凭证 +- **则** 用户通过认证`; + + const spec = new MarkdownParser(content).parseSpec('user-auth'); + + expect(spec.overview).toContain('本规范定义用户认证的需求'); + expect(spec.requirements).toHaveLength(1); + expect(spec.requirements[0].text).toBe('系统必须提供安全的用户登录能力。'); + expect(spec.requirements[0].scenarios).toHaveLength(1); + }); + + it('still parses English section headers', () => { + const content = `# Spec + +## Purpose +Overview here. + +## Requirements + +### Requirement: Auth +The system SHALL authenticate. + +#### Scenario: ok +- **WHEN** valid +- **THEN** allowed`; + + const spec = new MarkdownParser(content).parseSpec('en'); + expect(spec.requirements).toHaveLength(1); + }); + }); + + describe('MarkdownParser.parseChange', () => { + it('parses Chinese change headers (为什么 / 变更内容) and Chinese delta operations', () => { + const content = `# 添加用户认证 + +## 为什么 +我们需要实现用户认证来保护应用与用户数据。 + +## 变更内容 +- **user-auth:** 新增用户认证规范 +- **api:** 修改以加入认证端点 +- **db:** 移除旧的会话表 +- **legacy:** 重命名旧需求为新需求`; + + const change = new MarkdownParser(content).parseChange('add-auth'); + + expect(change.why).toContain('保护应用'); + expect(change.deltas).toHaveLength(4); + expect(change.deltas[0].operation).toBe('ADDED'); + expect(change.deltas[1].operation).toBe('MODIFIED'); + expect(change.deltas[2].operation).toBe('REMOVED'); + expect(change.deltas[3].operation).toBe('RENAMED'); + }); + + it('does not classify 更新 (update) as ADDED — bare 新 was narrowed to 新增/新建', () => { + const content = `# 变更 + +## 为什么 +因为我们需要更新现有端点,理由充分且足够长以通过校验。 + +## 变更内容 +- **api:** 更新认证端点 +- **svc:** 新建服务模块`; + + const change = new MarkdownParser(content).parseChange('c'); + expect(change.deltas).toHaveLength(2); + expect(change.deltas[0].operation).toBe('MODIFIED'); // 更新 → not ADDED + expect(change.deltas[1].operation).toBe('ADDED'); // 新建 → ADDED + }); + }); + + describe('extractRequirementsSection', () => { + it('parses a Chinese "## 需求" section with "### 需求:" headers', () => { + const result = extractRequirementsSection(`## 需求\n### 需求: 登录\n系统必须允许登录。\n`); + expect(result.bodyBlocks).toHaveLength(1); + expect(result.bodyBlocks[0].name).toBe('登录'); + }); + + it('accepts the full-width Chinese colon (:) in requirement headers', () => { + const result = extractRequirementsSection(`## 需求\n### 需求:登出\n系统必须允许登出。\n`); + expect(result.bodyBlocks).toHaveLength(1); + expect(result.bodyBlocks[0].name).toBe('登出'); + }); + }); + + describe('parseDeltaSpec', () => { + it('parses Chinese delta section headers (新增/修改/移除/重命名需求)', () => { + const content = `## 新增需求 + +### 需求: 新功能 +系统必须提供新功能。 + +## 移除需求 + +### 需求: 旧功能 + +## 重命名需求 + +- FROM: \`### 需求: 旧名\` +- TO: \`### 需求: 新名\``; + + const plan = parseDeltaSpec(content); + expect(plan.added.map((b) => b.name)).toEqual(['新功能']); + expect(plan.removed).toEqual(['旧功能']); + expect(plan.renamed).toEqual([{ from: '旧名', to: '新名' }]); + expect(plan.sectionPresence.added).toBe(true); + expect(plan.sectionPresence.removed).toBe(true); + expect(plan.sectionPresence.renamed).toBe(true); + }); + + it('still parses English delta section headers', () => { + const content = `## ADDED Requirements\n### Requirement: Foo\nThe system SHALL foo.\n`; + const plan = parseDeltaSpec(content); + expect(plan.added.map((b) => b.name)).toEqual(['Foo']); + }); + }); + + describe('ChangeParser.parseChangeWithDeltas', () => { + const tmpDirs: string[] = []; + + afterEach(async () => { + for (const dir of tmpDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } + }); + + it('parses Chinese delta spec files (新增需求 / 重命名需求)', async () => { + const changeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-cn-change-')); + tmpDirs.push(changeDir); + const specsDir = path.join(changeDir, 'specs', 'auth'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# 认证变更 + +## 新增需求 + +### 需求: 新功能 +系统必须提供新功能。 + +#### 场景: 使用新功能 +- **当** 用户触发新功能 +- **则** 系统响应 + +## 重命名需求 + +- FROM: \`### 需求: 旧名\` +- TO: \`### 需求: 新名\``; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec, 'utf8'); + + const changeContent = `# 认证变更\n\n## 为什么\n因为我们需要它,理由充分且足够长。\n\n## 变更内容\n- **auth:** 新增用户认证`; + const change = await new ChangeParser(changeContent, changeDir).parseChangeWithDeltas('auth-change'); + + const added = change.deltas.filter((d) => d.operation === 'ADDED'); + const renamed = change.deltas.filter((d) => d.operation === 'RENAMED'); + expect(added.length).toBeGreaterThan(0); + expect(renamed).toHaveLength(1); + expect(renamed[0].rename).toEqual({ from: '旧名', to: '新名' }); + }); + }); + + describe('RequirementSchema', () => { + it('accepts Chinese requirement keywords 必须 / 禁止', () => { + for (const text of ['系统必须记录日志。', '系统禁止明文存储密码。']) { + const result = RequirementSchema.safeParse({ + text, + scenarios: [{ rawText: 'Given / When / Then' }], + }); + expect(result.success).toBe(true); + } + }); + + it('still accepts English SHALL / MUST', () => { + const result = RequirementSchema.safeParse({ + text: 'The system SHALL log events.', + scenarios: [{ rawText: 'Given / When / Then' }], + }); + expect(result.success).toBe(true); + }); + }); + + describe('Validator (end-to-end, Chinese content)', () => { + const tmpDirs: string[] = []; + + afterEach(async () => { + for (const dir of tmpDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } + }); + + it('validates a fully-Chinese spec as valid', async () => { + const content = `# 用户认证规范 + +## 目的 +本规范定义系统用户认证与登录相关的功能需求,涵盖凭证校验、会话管理以及登录失败处理等方面的行为约束。 + +## 需求 + +### 需求: 安全登录 +系统必须提供安全的用户登录能力。 + +#### 场景: 成功登录 +- **当** 用户提供有效凭证 +- **则** 用户通过认证`; + + const report = await new Validator(true).validateSpecContent('user-auth', content); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + + it('validates a fully-Chinese delta change spec as valid', async () => { + const changeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-cn-i18n-')); + tmpDirs.push(changeDir); + const specsDir = path.join(changeDir, 'specs', 'auth'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# 认证变更 + +## 新增需求 + +### 需求: 记录日志 +系统必须记录所有认证事件。 + +#### 场景: 事件发生 +- **当** 发生一个认证事件 +- **则** 记录该事件`; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec, 'utf8'); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + }); +});