UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

201 lines 7.56 kB
export function normalizeRequirementName(name) { return name.trim(); } const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/; /** * Extracts the Requirements section from a spec file and parses requirement blocks. */ export function extractRequirementsSection(content) { const normalized = normalizeLineEndings(content); const lines = normalized.split('\n'); const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l)); if (reqHeaderIndex === -1) { // No requirements section; create an empty one at the end const before = content.trimEnd(); const headerLine = '## Requirements'; return { before: before ? before + '\n\n' : '', headerLine, preamble: '', bodyBlocks: [], after: '\n', }; } // Find end of this section: next line that starts with '## ' at same or higher level let endIndex = lines.length; for (let i = reqHeaderIndex + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { endIndex = i; break; } } const before = lines.slice(0, reqHeaderIndex).join('\n'); const headerLine = lines[reqHeaderIndex]; const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex); // Parse requirement blocks within section body const blocks = []; let cursor = 0; let preambleLines = []; // Collect preamble lines until first requirement header while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor])) { preambleLines.push(sectionBodyLines[cursor]); cursor++; } while (cursor < sectionBodyLines.length) { const headerStart = cursor; const headerLineCandidate = sectionBodyLines[cursor]; const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX); if (!headerMatch) { // Not a requirement header; skip line defensively cursor++; continue; } const name = normalizeRequirementName(headerMatch[1]); cursor++; // Gather lines until next requirement header or end of section const bodyLines = [headerLineCandidate]; while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { bodyLines.push(sectionBodyLines[cursor]); cursor++; } const raw = bodyLines.join('\n').trimEnd(); blocks.push({ headerLine: headerLineCandidate, name, raw }); } const after = lines.slice(endIndex).join('\n'); const preamble = preambleLines.join('\n').trimEnd(); return { before: before.trimEnd() ? before + '\n' : before, headerLine, preamble, bodyBlocks: blocks, after: after.startsWith('\n') ? after : '\n' + after, }; } function normalizeLineEndings(content) { return content.replace(/\r\n?/g, '\n'); } /** * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks. */ export function parseDeltaSpec(content) { const normalized = normalizeLineEndings(content); const sections = splitTopLevelSections(normalized); const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements'); const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements'); const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements'); const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements'); const added = parseRequirementBlocksFromSection(addedLookup.body); const modified = parseRequirementBlocksFromSection(modifiedLookup.body); const removedNames = parseRemovedNames(removedLookup.body); const renamedPairs = parseRenamedPairs(renamedLookup.body); return { added, modified, removed: removedNames, renamed: renamedPairs, sectionPresence: { added: addedLookup.found, modified: modifiedLookup.found, removed: removedLookup.found, renamed: renamedLookup.found, }, }; } function splitTopLevelSections(content) { const lines = content.split('\n'); const result = {}; const indices = []; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(##)\s+(.+)$/); if (m) { const level = m[1].length; // only care for '##' indices.push({ title: m[2].trim(), index: i, level }); } } for (let i = 0; i < indices.length; i++) { const current = indices[i]; const next = indices[i + 1]; const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\n'); result[current.title] = body; } return result; } function getSectionCaseInsensitive(sections, desired) { const target = desired.toLowerCase(); for (const [title, body] of Object.entries(sections)) { if (title.toLowerCase() === target) return { body, found: true }; } return { body: '', found: false }; } function parseRequirementBlocksFromSection(sectionBody) { if (!sectionBody) return []; const lines = normalizeLineEndings(sectionBody).split('\n'); const blocks = []; let i = 0; while (i < lines.length) { // Seek next requirement header while (i < lines.length && !/^###\s+Requirement:/.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 = [headerLine]; i++; while (i < lines.length && !/^###\s+Requirement:/.test(lines[i]) && !/^##\s+/.test(lines[i])) { buf.push(lines[i]); i++; } blocks.push({ headerLine, name, raw: buf.join('\n').trimEnd() }); } return blocks; } function parseRemovedNames(sectionBody) { if (!sectionBody) return []; const names = []; const lines = normalizeLineEndings(sectionBody).split('\n'); for (const line of lines) { const m = line.match(REQUIREMENT_HEADER_REGEX); if (m) { names.push(normalizeRequirementName(m[1])); continue; } // Also support bullet list of headers const bullet = line.match(/^\s*-\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); if (bullet) { names.push(normalizeRequirementName(bullet[1])); } } return names; } function parseRenamedPairs(sectionBody) { if (!sectionBody) return []; const pairs = []; const lines = normalizeLineEndings(sectionBody).split('\n'); let current = {}; 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*$/); if (fromMatch) { current.from = normalizeRequirementName(fromMatch[1]); } else if (toMatch) { current.to = normalizeRequirementName(toMatch[1]); if (current.from && current.to) { pairs.push({ from: current.from, to: current.to }); current = {}; } } } return pairs; } //# sourceMappingURL=requirement-blocks.js.map