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
2 changes: 1 addition & 1 deletion openspec/specs/cli-update/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ The archive slash command template SHALL support optional change ID arguments fo

## Edge Cases

### Requirement: Error Handling
### Error Handling

The command SHALL handle edge cases gracefully.

Expand Down
14 changes: 7 additions & 7 deletions openspec/specs/openspec-conventions/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ The system SHALL follow these principles:

## Directory Structure

### Requirement: Project Structure
### Project Structure

An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.

Expand Down Expand Up @@ -285,7 +285,7 @@ openspec/

## Specification Format

### Requirement: Structured Format for Behavioral Specs
### Behavioral Spec Format

Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.

Expand Down Expand Up @@ -316,7 +316,7 @@ Behavioral specifications SHALL use a structured format with consistent section

## Change Storage Convention

### Requirement: Header-Based Requirement Identification
### Header-Based Requirement Identification

Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.

Expand Down Expand Up @@ -345,7 +345,7 @@ Requirement headers SHALL serve as unique identifiers for programmatic matching
- **THEN** ensure no duplicate headers exist within a spec
- **AND** validation tools SHALL flag duplicate headers as errors

### Requirement: Change Storage Convention
### Change Storage Convention

Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.

Expand Down Expand Up @@ -388,7 +388,7 @@ The `changes/[name]/specs/` directory SHALL contain:
- `-` for REMOVED (red)
- `→` for RENAMED (cyan)

### Requirement: Archive Process Enhancement
### Archive Process Enhancement

The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.

Expand All @@ -411,7 +411,7 @@ The archive process SHALL programmatically apply delta changes to current specif
- **AND** require manual resolution before proceeding
- **AND** provide clear guidance on resolving conflicts

### Requirement: Proposal Format
### Proposal Format

Proposals SHALL explicitly document all changes with clear from/to comparisons.

Expand Down Expand Up @@ -444,7 +444,7 @@ The change process SHALL follow these states:

## Viewing Changes

### Requirement: Change Review
### Change Review

The system SHALL support multiple methods for reviewing proposed changes.

Expand Down
17 changes: 13 additions & 4 deletions src/core/parsers/change-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,21 @@ export class ChangeParser extends MarkdownParser {
private parseSectionsFromContent(content: string): Section[] {
const normalizedContent = ChangeParser.normalizeContent(content);
const lines = normalizedContent.split('\n');
const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines);
const sections: Section[] = [];
const stack: Section[] = [];

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (codeFenceLineMask[i]) {
continue;
}
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);

if (headerMatch) {
const level = headerMatch[1].length;
const title = headerMatch[2].trim();
const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);

const section = {
level,
Expand All @@ -215,12 +219,17 @@ export class ChangeParser extends MarkdownParser {
return sections;
}

private getContentUntilNextHeaderFromLines(lines: string[], startLine: number, currentLevel: number): string[] {
private getContentUntilNextHeaderFromLines(
lines: string[],
codeFenceLineMask: boolean[],
startLine: number,
currentLevel: number
): string[] {
const contentLines: string[] = [];

for (let i = startLine; i < lines.length; i++) {
const line = lines[i];
const headerMatch = line.match(/^(#{1,6})\s+/);
const headerMatch = codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);

if (headerMatch && headerMatch[1].length <= currentLevel) {
break;
Expand All @@ -231,4 +240,4 @@ export class ChangeParser extends MarkdownParser {

return contentLines;
}
}
}
57 changes: 55 additions & 2 deletions src/core/parsers/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,68 @@ export interface Section {

export class MarkdownParser {
private lines: string[];
private codeFenceLineMask: boolean[];
private currentLine: number;

constructor(content: string) {
const normalized = MarkdownParser.normalizeContent(content);
this.lines = normalized.split('\n');
this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines);
this.currentLine = 0;
}

protected static normalizeContent(content: string): string {
return content.replace(/\r\n?/g, '\n');
}

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
);
}

parseSpec(name: string): Spec {
const sections = this.parseSections();
const purpose = this.findSection(sections, 'Purpose')?.content || '';
Expand Down Expand Up @@ -81,6 +131,9 @@ export class MarkdownParser {

for (let i = 0; i < this.lines.length; i++) {
const line = this.lines[i];
if (this.codeFenceLineMask[i]) {
continue;
}
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);

if (headerMatch) {
Expand Down Expand Up @@ -117,7 +170,7 @@ export class MarkdownParser {

for (let i = startLine; i < this.lines.length; i++) {
const line = this.lines[i];
const headerMatch = line.match(/^(#{1,6})\s+/);
const headerMatch = this.codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);

if (headerMatch && headerMatch[1].length <= currentLevel) {
break;
Expand Down Expand Up @@ -234,4 +287,4 @@ export class MarkdownParser {

return deltas;
}
}
}
117 changes: 117 additions & 0 deletions src/core/parsers/spec-structure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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;
const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/;
Comment on lines +1 to +4
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Support Markdown-valid indentation bounds for headings/fences.

Current patterns can miss valid indented headings (1–3 spaces) and can over-match 4-space-indented fence-like lines.

💡 Proposed fix
-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;
-const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/;
+const REQUIREMENTS_SECTION_HEADER = /^\s{0,3}##\s+Requirements\s*$/i;
+const TOP_LEVEL_SECTION_HEADER = /^\s{0,3}##\s+/;
+const DELTA_HEADER = /^\s{0,3}##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
+const REQUIREMENT_HEADER = /^\s{0,3}###\s+Requirement:\s*(.+)\s*$/;

-    const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
+    const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);

Also applies to: 82-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/parsers/spec-structure.ts` around lines 1 - 4, Update the four
header regexes to allow 0–3 leading spaces (Markdown-valid indentation) and
avoid matching 4+ space indented code blocks by prefixing each pattern with a
non-capturing optional-space group like (?: {0,3}), e.g. change
REQUIREMENTS_SECTION_HEADER, TOP_LEVEL_SECTION_HEADER, DELTA_HEADER and
REQUIREMENT_HEADER to start with /^(?: {0,3}).../ while keeping the rest of each
pattern (the header text, case-insensitivity and end anchors) unchanged so
headings are recognized when indented up to 3 spaces but not mistaken for
4-space code fences.


export interface MainSpecStructureIssue {
kind: 'delta-header' | 'requirement-outside-requirements';
line: number;
header: string;
message: string;
}

export function findMainSpecStructureIssues(content: string): MainSpecStructureIssue[] {
const normalized = content.replace(/\r\n?/g, '\n');
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
const lines = stripped.split('\n');
const issues: MainSpecStructureIssue[] = [];

const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
let requirementsEndIndex = lines.length;

if (requirementsHeaderIndex !== -1) {
for (let i = requirementsHeaderIndex + 1; i < lines.length; i++) {
if (TOP_LEVEL_SECTION_HEADER.test(lines[i])) {
requirementsEndIndex = i;
break;
}
}
}

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
continue;
}

if (DELTA_HEADER.test(line)) {
issues.push({
kind: 'delta-header',
line: i + 1,
header: trimmed,
message:
`Main spec contains delta header "${trimmed}". ` +
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
'and truncate the parsed ## Requirements section.',
});
continue;
}

const requirementMatch = line.match(REQUIREMENT_HEADER);
if (!requirementMatch) {
continue;
}

const insideRequirements =
requirementsHeaderIndex !== -1 &&
i > requirementsHeaderIndex &&
i < requirementsEndIndex;

if (!insideRequirements) {
issues.push({
kind: 'requirement-outside-requirements',
line: i + 1,
header: trimmed,
message:
`Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
});
}
}

return issues;
}

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
);
}
11 changes: 11 additions & 0 deletions src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
normalizeRequirementName,
type RequirementBlock,
} from './parsers/requirement-blocks.js';
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
import { Validator } from './validation/validator.js';

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -223,6 +224,16 @@ export async function buildUpdatedSpec(
targetContent = buildSpecSkeleton(specName, changeName);
}

const structureIssues = findMainSpecStructureIssues(targetContent);
if (structureIssues.length > 0) {
const details = structureIssues
.map(issue => `line ${issue.line}: ${issue.message}`)
.join('\n');
throw new Error(
`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`
);
}

// Extract requirements section and build name->block map
const parts = extractRequirementsSection(targetContent);
const nameToBlock = new Map<string, RequirementBlock>();
Expand Down
10 changes: 10 additions & 0 deletions src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
VALIDATION_MESSAGES
} from './constants.js';
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
import { FileSystemUtils } from '../../utils/file-system.js';

export class Validator {
Expand Down Expand Up @@ -288,6 +289,15 @@ export class Validator {

private applySpecRules(spec: Spec, content: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];

for (const structuralIssue of findMainSpecStructureIssues(content)) {
issues.push({
level: 'ERROR',
path: 'file',
line: structuralIssue.line,
message: structuralIssue.message,
});
}

if (spec.overview.length < MIN_PURPOSE_LENGTH) {
issues.push({
Expand Down
Loading
Loading