diff --git a/lib/dev/plan/content.rb b/lib/dev/plan/content.rb index 420692c..024a0a6 100644 --- a/lib/dev/plan/content.rb +++ b/lib/dev/plan/content.rb @@ -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 @@ -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. diff --git a/lib/dev/plan/frontmatter.rb b/lib/dev/plan/frontmatter.rb index 07869c0..2078533 100644 --- a/lib/dev/plan/frontmatter.rb +++ b/lib/dev/plan/frontmatter.rb @@ -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) diff --git a/test/dev/plan/content_test.rb b/test/dev/plan/content_test.rb index 8e84918..a747f9a 100644 --- a/test/dev/plan/content_test.rb +++ b/test/dev/plan/content_test.rb @@ -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" diff --git a/test/dev/plan/frontmatter_test.rb b/test/dev/plan/frontmatter_test.rb index 43f5e44..754b357 100644 --- a/test/dev/plan/frontmatter_test.rb +++ b/test/dev/plan/frontmatter_test.rb @@ -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"