@fission-ai/openspec
Version:
AI-native system for spec-driven development
201 lines • 7.56 kB
JavaScript
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