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
23 changes: 14 additions & 9 deletions src/core/parsers/change-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export class ChangeParser extends MarkdownParser {

async parseChangeWithDeltas(name: string): Promise<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 章节');
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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();
Expand Down
22 changes: 13 additions & 9 deletions src/core/parsers/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 章节');
Expand All @@ -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 章节');
}
Expand Down Expand Up @@ -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';
}

Expand Down
46 changes: 36 additions & 10 deletions src/core/parsers/requirement-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@ 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.
*/
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
Expand Down Expand Up @@ -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++;
}
Expand All @@ -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++;
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -163,8 +170,19 @@ function splitTopLevelSections(content: string): Record<string, string> {

function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {
const target = desired.toLowerCase();
// Accept Chinese delta section titles as equivalents of the English headers.
const chineseAlternates: Record<string, string> = {
'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 };
}
Expand All @@ -176,15 +194,15 @@ 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);
if (!m) { i++; continue; }
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++;
}
Expand All @@ -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]));
}
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/core/schemas/base.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading