Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/fence-aware-delta-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@fission-ai/openspec": patch
---

### Fixed

- Ignore Markdown structure (requirement headers, delta sections, scenarios, REMOVED/RENAMED entries) that appears inside fenced code blocks when parsing delta specs. Previously a fenced `### Requirement:` example was parsed as a real (phantom) requirement, producing spurious `validate` errors and risking incorrect `archive` output. Fenced-code detection is now shared across the Markdown parsers so `validate` and `archive` behave consistently.
62 changes: 62 additions & 0 deletions src/core/parsers/code-fence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Shared fenced-code-block detection for the Markdown parsers.
*
* Several parsers need to ignore Markdown structure (headers, requirement
* blocks, scenarios, delta sections) that appears inside fenced code blocks.
* Keeping this logic in one place avoids the drift that previously left
* `requirement-blocks.ts` treating fenced `### Requirement:` lines as real
* requirements during validation and archiving.
*/

interface ActiveFence {
marker: '`' | '~';
length: number;
}

function getFenceMarker(line: string): ActiveFence | null {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (!fenceMatch) {
return null;
}

return {
marker: fenceMatch[1][0] as '`' | '~',
length: fenceMatch[1].length,
};
}

function isClosingFence(line: string, activeFence: ActiveFence): boolean {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
return Boolean(
fenceMatch &&
fenceMatch[1][0] === activeFence.marker &&
fenceMatch[1].length >= activeFence.length
);
}

/**
* Builds a per-line mask where `true` marks a line that is part of a fenced
* code block (including the opening and closing fence lines themselves).
*/
export function buildCodeFenceMask(lines: string[]): boolean[] {
const mask = new Array<boolean>(lines.length).fill(false);
let activeFence: ActiveFence | null = null;

for (let i = 0; i < lines.length; i++) {
if (!activeFence) {
const fence = getFenceMarker(lines[i]);
if (fence) {
activeFence = fence;
mask[i] = true;
}
continue;
}

mask[i] = true;
if (isClosingFence(lines[i], activeFence)) {
activeFence = null;
}
}

return mask;
}
47 changes: 2 additions & 45 deletions src/core/parsers/markdown-parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';
import { buildCodeFenceMask } from './code-fence.js';

export interface Section {
level: number;
Expand All @@ -24,51 +25,7 @@ export class MarkdownParser {
}

protected static buildCodeFenceMask(lines: string[]): boolean[] {
const mask = new Array(lines.length).fill(false);
let activeFence: { marker: '`' | '~'; length: number } | null = null;

for (let i = 0; i < lines.length; i++) {
const fence = MarkdownParser.getFenceMarker(lines[i]);

if (!activeFence) {
if (fence) {
activeFence = fence;
mask[i] = true;
}
continue;
}

mask[i] = true;
if (MarkdownParser.isClosingFence(lines[i], activeFence)) {
activeFence = null;
}
}

return mask;
}

private static getFenceMarker(line: string): { marker: '`' | '~'; length: number } | null {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (!fenceMatch) {
return null;
}

return {
marker: fenceMatch[1][0] as '`' | '~',
length: fenceMatch[1].length,
};
}

private static isClosingFence(
line: string,
activeFence: { marker: '`' | '~'; length: number }
): boolean {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
return Boolean(
fenceMatch &&
fenceMatch[1][0] === activeFence.marker &&
fenceMatch[1].length >= activeFence.length
);
return buildCodeFenceMask(lines);
}

parseSpec(name: string): Spec {
Expand Down
94 changes: 61 additions & 33 deletions src/core/parsers/requirement-blocks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { buildCodeFenceMask } from './code-fence.js';

export interface RequirementBlock {
headerLine: string; // e.g., '### Requirement: Something'
name: string; // e.g., 'Something'
Expand All @@ -24,7 +26,8 @@ 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 fenceMask = buildCodeFenceMask(lines);
const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && /^##\s+Requirements\s*$/i.test(l));

if (reqHeaderIndex === -1) {
// No requirements section; create an empty one at the end
Expand All @@ -42,7 +45,7 @@ export function extractRequirementsSection(content: string): RequirementsSection
// 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])) {
if (!fenceMask[i] && /^##\s+/.test(lines[i])) {
endIndex = i;
break;
}
Expand All @@ -51,32 +54,36 @@ export function extractRequirementsSection(content: string): RequirementsSection
const before = lines.slice(0, reqHeaderIndex).join('\n');
const headerLine = lines[reqHeaderIndex];
const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex);
const sectionBodyMask = fenceMask.slice(reqHeaderIndex + 1, endIndex);
const isRequirementHeader = (cursor: number): boolean =>
!sectionBodyMask[cursor] && REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor]);
const isTopLevelHeader = (cursor: number): boolean =>
!sectionBodyMask[cursor] && /^##\s+/.test(sectionBodyLines[cursor]);

// Parse requirement blocks within section body
const blocks: RequirementBlock[] = [];
let cursor = 0;
let preambleLines: string[] = [];

// Collect preamble lines until first requirement header
while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor])) {
while (cursor < sectionBodyLines.length && !isRequirementHeader(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) {
if (!isRequirementHeader(cursor)) {
// Not a requirement header; skip line defensively
cursor++;
continue;
}
const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX)!;
const name = normalizeRequirementName(headerMatch[1]);
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 && !isRequirementHeader(cursor) && !isTopLevelHeader(cursor)) {
bodyLines.push(sectionBodyLines[cursor]);
cursor++;
}
Expand Down Expand Up @@ -113,12 +120,24 @@ function normalizeLineEndings(content: string): string {
return content.replace(/\r\n?/g, '\n');
}

/**
* A slice of a document represented as its lines plus a parallel mask marking
* lines that live inside fenced code blocks (which must be ignored when
* detecting Markdown structure).
*/
interface SectionBody {
lines: string[];
fenceMask: boolean[];
}

/**
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
*/
export function parseDeltaSpec(content: string): DeltaPlan {
const normalized = normalizeLineEndings(content);
const sections = splitTopLevelSections(normalized);
const lines = normalized.split('\n');
const fenceMask = buildCodeFenceMask(lines);
const sections = splitTopLevelSections(lines, fenceMask);
const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
Expand All @@ -141,50 +160,55 @@ export function parseDeltaSpec(content: string): DeltaPlan {
};
}

function splitTopLevelSections(content: string): Record<string, string> {
const lines = content.split('\n');
const result: Record<string, string> = {};
const indices: Array<{ title: string; index: number; level: number }> = [];
function splitTopLevelSections(lines: string[], fenceMask: boolean[]): Record<string, SectionBody> {
const result: Record<string, SectionBody> = {};
const indices: Array<{ title: string; index: number }> = [];
for (let i = 0; i < lines.length; i++) {
if (fenceMask[i]) continue;
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 });
indices.push({ title: m[2].trim(), index: i });
}
}
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;
const end = next ? next.index : lines.length;
result[current.title] = {
lines: lines.slice(current.index + 1, end),
fenceMask: fenceMask.slice(current.index + 1, end),
};
}
return result;
}

function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {
const EMPTY_SECTION_BODY: SectionBody = { lines: [], fenceMask: [] };

function getSectionCaseInsensitive(sections: Record<string, SectionBody>, desired: string): { body: SectionBody; found: boolean } {
const target = desired.toLowerCase();
for (const [title, body] of Object.entries(sections)) {
if (title.toLowerCase() === target) return { body, found: true };
}
return { body: '', found: false };
return { body: EMPTY_SECTION_BODY, found: false };
}

function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
if (!sectionBody) return [];
const lines = normalizeLineEndings(sectionBody).split('\n');
function parseRequirementBlocksFromSection(sectionBody: SectionBody): RequirementBlock[] {
const { lines, fenceMask } = sectionBody;
if (lines.length === 0) return [];
const isRequirementHeader = (i: number): boolean => !fenceMask[i] && REQUIREMENT_HEADER_REGEX.test(lines[i]);
const isTopLevelHeader = (i: number): boolean => !fenceMask[i] && /^##\s+/.test(lines[i]);
const blocks: RequirementBlock[] = [];
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 && !isRequirementHeader(i)) i++;
if (i >= lines.length) break;
const headerLine = lines[i];
const m = headerLine.match(REQUIREMENT_HEADER_REGEX);
if (!m) { i++; continue; }
const m = headerLine.match(REQUIREMENT_HEADER_REGEX)!;
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 && !isRequirementHeader(i) && !isTopLevelHeader(i)) {
buf.push(lines[i]);
i++;
}
Expand All @@ -193,11 +217,13 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc
return blocks;
}

function parseRemovedNames(sectionBody: string): string[] {
if (!sectionBody) return [];
function parseRemovedNames(sectionBody: SectionBody): string[] {
const { lines, fenceMask } = sectionBody;
if (lines.length === 0) return [];
const names: string[] = [];
const lines = normalizeLineEndings(sectionBody).split('\n');
for (const line of lines) {
for (let i = 0; i < lines.length; i++) {
if (fenceMask[i]) continue;
const line = lines[i];
const m = line.match(REQUIREMENT_HEADER_REGEX);
if (m) {
names.push(normalizeRequirementName(m[1]));
Expand All @@ -212,12 +238,14 @@ function parseRemovedNames(sectionBody: string): string[] {
return names;
}

function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> {
if (!sectionBody) return [];
function parseRenamedPairs(sectionBody: SectionBody): Array<{ from: string; to: string }> {
const { lines, fenceMask } = sectionBody;
if (lines.length === 0) return [];
const pairs: Array<{ from: string; to: string }> = [];
const lines = normalizeLineEndings(sectionBody).split('\n');
let current: { from?: string; to?: string } = {};
for (const line of lines) {
for (let i = 0; i < lines.length; i++) {
if (fenceMask[i]) continue;
const line = lines[i];
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) {
Expand Down
43 changes: 4 additions & 39 deletions src/core/parsers/spec-structure.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { buildCodeFenceMask } from './code-fence.js';

const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
Expand Down Expand Up @@ -75,43 +77,6 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI

export function stripFencedCodeBlocksPreservingLines(content: string): string {
const lines = content.split('\n');
const output: string[] = [];
let activeFence: { marker: '`' | '~'; length: number } | null = null;

for (const line of lines) {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);

if (!activeFence) {
if (fenceMatch) {
activeFence = {
marker: fenceMatch[1][0] as '`' | '~',
length: fenceMatch[1].length,
};
output.push('');
} else {
output.push(line);
}
continue;
}

output.push('');

if (isClosingFence(line, activeFence)) {
activeFence = null;
}
}

return output.join('\n');
}

function isClosingFence(
line: string,
activeFence: { marker: '`' | '~'; length: number }
): boolean {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
return Boolean(
fenceMatch &&
fenceMatch[1][0] === activeFence.marker &&
fenceMatch[1].length >= activeFence.length
);
const mask = buildCodeFenceMask(lines);
return lines.map((line, i) => (mask[i] ? '' : line)).join('\n');
}
Loading