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
32 changes: 29 additions & 3 deletions lib/dev/plan/content.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,19 +23,21 @@ class << self
# then optional frontmatter, then body. When frontmatter sits above the
# ai-flow header (Cursor's plan tool writes that layout, with a blank
# line after the closing fence), both are still recognized; {#render}
# rewrites canonical order.
# rewrites canonical order. Stacked frontmatter blocks — an empty one
# Cursor wrote above the real one — are collapsed to the block that
# carries content.
#
# @param content [String]
# @return [Content]
def parse(content)
header, remainder = Header.split(without_leading_blank_lines(content))
if header
frontmatter, body = Frontmatter.split(remainder)
frontmatter, body = split_stacked_frontmatter(remainder)
return new(header: header, frontmatter: frontmatter, body: body)
end

# Frontmatter may sit above a misplaced ai-flow header.
frontmatter, after_frontmatter = Frontmatter.split(content)
frontmatter, after_frontmatter = split_stacked_frontmatter(content)
if frontmatter
header, body = Header.split(without_leading_blank_lines(after_frontmatter))
# No header: keep the body byte-exact (the stripped copy was only
Expand All@@ -48,6 +50,30 @@ def parse(content)

private

# Peel the leading frontmatter, collapsing stacked blocks: while the
# peeled block is empty and another block follows (blank lines between
# them tolerated), drop it in favor of the next one. Peeling stops at
# the first block that carries content, so a body that merely starts
# with something frontmatter-shaped is never consumed. A solitary empty
# block (a fresh Cursor draft) is kept as-is.
#
# @param content [String]
# @return [Array(String | nil, String)] the surviving frontmatter block
# (or nil) and the remainder
def split_stacked_frontmatter(content)
frontmatter, remainder = Frontmatter.split(content)
return [nil, content] if frontmatter.nil?

while Frontmatter.empty?(frontmatter)
next_frontmatter, next_remainder = Frontmatter.split(without_leading_blank_lines(remainder))
break if next_frontmatter.nil?

frontmatter = next_frontmatter
remainder = next_remainder
end
[frontmatter, remainder]
end

# The Header pattern is anchored at the start of its input, so blank
# lines ahead of the comment (Cursor writes one after its frontmatter
# fence) are skipped before detection — and only for detection.
Expand Down
19 changes: 19 additions & 0 deletions lib/dev/plan/frontmatter.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,8 +36,27 @@ def split(content)
[frontmatter, body]
end

# True when the block carries no content — every value in its mapping
# is nil, false, an empty string, or an empty collection. Cursor writes
# such a block (`name: ""`, `todos: []`, …) for an unfilled draft.
#
# @param frontmatter [String] a block produced by {.split}, fences included
# @return [Boolean]
def empty?(frontmatter)
interior = frontmatter.lines[1..-2].join
YAML.safe_load(interior).values.all? { |value| blank_value?(value) }
end

private

# @param value [Object] a value from the frontmatter's YAML mapping
# @return [Boolean]
def blank_value?(value)
return true if value.nil? || value == false

value.respond_to?(:empty?) && value.empty?
end

# @param line [String]
# @return [Boolean]
def fence?(line)
Expand Down
61 changes: 61 additions & 0 deletions test/dev/plan/content_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,67 @@ class Dev::Plan::ContentTest < Minitest::Test
nil
end

test "parse collapses an empty frontmatter block stacked above the real one" do
Given "the mangled double-frontmatter layout from dev#60: empty block, then a de-fenced real block, then the header"
header = Dev::Plan::Header.new(owner_repo: "d3mlabs/plans", number: 13, synced_at: "2026-01-01T00:00:00Z")
body = "# Self-learning practices loop\n\nprose\n"
empty_frontmatter = <<~YAML
---
name: ""
overview: ""
todos: []
isProject: false
---
YAML
real_frontmatter = <<~YAML
---

name: Self-learning practices loop
overview: Keep practices current
todos: []
isProject: false
---
YAML
raw = "#{empty_frontmatter}\n#{real_frontmatter}\n#{header.render}#{body}"

When "parsing and re-rendering"
plan = Dev::Plan::Content.parse(raw)

Then "the empty block is dropped, all three layers are recovered, and render is canonical"
plan.header.issue_ref == "d3mlabs/plans#13"
plan.frontmatter == real_frontmatter
plan.body == body
plan.render == "#{header.render}#{real_frontmatter}#{body}"

Cleanup
nil
end

test "parse keeps a solitary empty frontmatter block on a fresh draft" do
Given "an unlinked draft whose frontmatter Cursor has not filled in yet"
empty_frontmatter = <<~YAML
---
name: ""
overview: ""
todos: []
isProject: false
---
YAML
body = "# Draft\n"
raw = "#{empty_frontmatter}#{body}"

When "parsing it"
plan = Dev::Plan::Content.parse(raw)

Then "the empty block survives as the draft's frontmatter"
plan.header.nil?
plan.frontmatter == empty_frontmatter
plan.body == body

Cleanup
nil
end

test "parse tolerates a draft with frontmatter and no ai-flow header" do
Given "an unlinked Cursor draft"
body = "# Draft\n"
Expand Down
26 changes: 26 additions & 0 deletions test/dev/plan/frontmatter_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,6 +79,32 @@ class Dev::Plan::FrontmatterTest < Minitest::Test
nil
end

test "empty? is true for Cursor's unfilled draft block" do
Given "the block Cursor writes for a fresh draft"
frontmatter = <<~YAML
---
name: ""
overview: ""
todos: []
isProject: false
---
YAML

Expect "it carries no content"
Dev::Plan::Frontmatter.empty?(frontmatter)

Cleanup
nil
end

test "empty? is false once any field carries content" do
Expect "a named block is not empty"
!Dev::Plan::Frontmatter.empty?(CURSOR_FRONTMATTER)

Cleanup
nil
end

test "split leaves a YAML sequence (non-mapping) alone" do
Given "fences whose interior is a YAML list"
content = "---\n- item\n- other\n---\n# Title\n"
Expand Down
Loading