Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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