diff --git a/lib/dev/plan.rb b/lib/dev/plan.rb index b36a0e7..cfe2e3e 100644 --- a/lib/dev/plan.rb +++ b/lib/dev/plan.rb @@ -2,6 +2,8 @@ require "dev/plan/executor" require "dev/plan/header" +require "dev/plan/frontmatter" +require "dev/plan/content" require "dev/plan/settings" require "dev/plan/github_issues" require "dev/plan/workspace" diff --git a/lib/dev/plan/accessor.rb b/lib/dev/plan/accessor.rb index 47dc051..1280cc1 100644 --- a/lib/dev/plan/accessor.rb +++ b/lib/dev/plan/accessor.rb @@ -102,9 +102,8 @@ def link(args, out:) def link_to_existing(number, file, org:, out:) path = file ? Pathname.new(file) : sole_unlinked_plan - content = path.read - header, body = Header.split(content) - raise UsageError, "#{path} is already linked to #{header.issue_ref}" if header + plan = Content.parse(path.read) + raise UsageError, "#{path} is already linked to #{plan.header.issue_ref}" if plan.header owner_repo = target_repo(org:) issue = @issues.get(owner_repo, number) @@ -112,7 +111,7 @@ def link_to_existing(number, file, org:, out:) # file is a draft ahead of the issue until `dev plan push` publishes it. new_header = Header.new(owner_repo: owner_repo, number: issue.number, synced_at: issue.updated_at) target = move_into_convention(path, owner_repo, issue) - target.write(new_header.render + body) + target.write(plan.with_header(new_header).render) @merge_base.write(owner_repo, issue.number, Plan.from_issue_body(issue.body)) out.puts "dev: linked #{target} to #{owner_repo}##{issue.number} (#{issue.html_url})" out.puts "dev: local content kept — run `dev plan push` to publish it." @@ -122,15 +121,14 @@ def create_from_file(file, org:, out:) path = Pathname.new(file) raise UsageError, "no such plan file: #{path}" unless path.exist? - content = path.read - header, body = Header.split(content) - raise UsageError, "#{path} is already linked to #{header.issue_ref}" if header + plan = Content.parse(path.read) + raise UsageError, "#{path} is already linked to #{plan.header.issue_ref}" if plan.header owner_repo = target_repo(org:) - title = extract_title(body) || path.basename(".plan.md").to_s - issue = @issues.create(owner_repo, title: title, body: Plan.to_issue_body(body)) + title = extract_title(plan.body) || path.basename(".plan.md").to_s + issue = @issues.create(owner_repo, title: title, body: Plan.to_issue_body(plan.body)) target = move_into_convention(path, owner_repo, issue) - write_linked_plan(owner_repo, issue, body, path: target) + write_linked_plan(owner_repo, issue, plan.body, path: target, frontmatter: plan.frontmatter) out.puts "dev: created #{owner_repo}##{issue.number} from #{path} (#{issue.html_url})" out.puts "dev: plan file: #{target}" end @@ -156,30 +154,31 @@ def pull(args, out:) return end - _header, local_body = Header.split(path.read) + plan = Content.parse(path.read) base = @merge_base.read(owner_repo, number) - local_dirty = base.nil? ? false : local_body != base + local_dirty = base.nil? ? false : plan.body != base remote_dirty = base.nil? ? true : remote_body != base if !local_dirty - write_linked_plan(owner_repo, issue, remote_body, path: path) + write_linked_plan(owner_repo, issue, remote_body, path: path, frontmatter: plan.frontmatter) out.puts(remote_dirty ? "dev: pulled #{owner_repo}##{number} into #{path}" : "dev: #{path} is already up to date.") elsif !remote_dirty out.puts "dev: local plan is ahead of #{owner_repo}##{number} — nothing to pull. Run `dev plan push`." elsif merge - merge_pull(path, issue, owner_repo, number, local_body, base, remote_body, out:) + merge_pull(path, issue, owner_repo, number, plan, base, remote_body, out:) else raise "both #{path} and #{owner_repo}##{number} changed since the last sync — " \ "run `dev plan pull #{number} --merge`." end end - def merge_pull(path, issue, owner_repo, number, local_body, base, remote_body, out:) - result = Merge.three_way(local: local_body, base: base, remote: remote_body, executor: @executor) + def merge_pull(path, issue, owner_repo, number, plan, base, remote_body, out:) + result = Merge.three_way(local: plan.body, base: base, remote: remote_body, executor: @executor) # The remote becomes the new base either way: the merged local copy is - # now "ahead" of the issue, and `push` publishes it. + # now "ahead" of the issue, and `push` publishes it. Frontmatter is + # carried through from the local side untouched. header = Header.new(owner_repo: owner_repo, number: number, synced_at: issue.updated_at) - path.write(header.render + result.content) + path.write(plan.with_header(header).with_body(result.content).render) @merge_base.write(owner_repo, number, remote_body) if result.conflicts? out.puts "dev: merged with conflicts — resolve the markers in #{path}, then run `dev plan push`." @@ -197,10 +196,11 @@ def push(args, out:) raise UsageError, "usage: dev plan push []" unless args.empty? path = file ? Pathname.new(file) : sole_linked_plan - header, body = Header.split(path.read) - raise UsageError, "#{path} has no ai-flow header — link it first with `dev plan link`." unless header - raise "#{path} contains unresolved merge conflict markers — resolve them before pushing." if body.include?("<<<<<<<") + plan = Content.parse(path.read) + raise UsageError, "#{path} has no ai-flow header — link it first with `dev plan link`." unless plan.header + raise "#{path} contains unresolved merge conflict markers — resolve them before pushing." if plan.body.include?("<<<<<<<") + header = plan.header issue = @issues.get(header.owner_repo, header.number) remote_body = Plan.from_issue_body(issue.body) base = @merge_base.read(header.owner_repo, header.number) @@ -214,19 +214,19 @@ def push(args, out:) "run `dev plan pull #{header.number} --merge`, then push again." end - if body == remote_body - record_sync(path, header, issue, body) + if plan.body == remote_body + record_sync(path, plan, issue) out.puts "dev: #{header.issue_ref} is already in sync." return end - title = extract_title(body) + title = extract_title(plan.body) updated = @issues.update( header.owner_repo, header.number, - body: Plan.to_issue_body(body), + body: Plan.to_issue_body(plan.body), title: (title if title && title != issue.title), ) - record_sync(path, header, updated, body) + record_sync(path, plan, updated) out.puts "dev: pushed #{path} to #{header.issue_ref} (#{updated.html_url})" end @@ -244,8 +244,8 @@ def hook_after_edit(input, out:) return unless path.to_s.end_with?(".plan.md") && path.exist? return unless path.expand_path.to_s.start_with?(@workspace.plans_dir.expand_path.to_s) - header, _body = Header.split(path.read) - return unless header + plan = Content.parse(path.read) + return unless plan.header push([path.to_s], out:) end @@ -260,10 +260,10 @@ def status(out:) end files.each do |path| - header, body = Header.split(path.read) - issue = @issues.get(header.owner_repo, header.number) - state = sync_state(header, body, Plan.from_issue_body(issue.body)) - out.puts "#{state.ljust(10)} #{header.issue_ref.ljust(30)} #{path}" + plan = Content.parse(path.read) + issue = @issues.get(plan.header.owner_repo, plan.header.number) + state = sync_state(plan.header, plan.body, Plan.from_issue_body(issue.body)) + out.puts "#{state.ljust(10)} #{plan.header.issue_ref.ljust(30)} #{path}" end end @@ -286,22 +286,31 @@ def target_repo(org:) org ? @settings.plans_repo : @workspace.origin_repo end - # Write the plan file (header + body) and refresh the merge base — the - # single definition of "synced". + # Write the plan file (header + optional frontmatter + markdown body) and + # refresh the merge base — the single definition of "synced". The merge + # base stores the markdown body only. # + # @param owner_repo [String] + # @param issue [Dev::Plan::GithubIssues::Issue] + # @param body [String] markdown body + # @param path [Pathname, nil] + # @param frontmatter [String, nil] Cursor YAML block to preserve locally # @return [Pathname] the written path - def write_linked_plan(owner_repo, issue, body, path: nil) + def write_linked_plan(owner_repo, issue, body, path: nil, frontmatter: nil) path ||= @workspace.plan_path(owner_repo, issue.number, issue.title) header = Header.new(owner_repo: owner_repo, number: issue.number, synced_at: issue.updated_at) FileUtils.mkdir_p(path.dirname) - path.write(header.render + body) + path.write(Content.new(header: header, frontmatter: frontmatter, body: body).render) @merge_base.write(owner_repo, issue.number, body) path end - def record_sync(path, header, issue, body) - path.write(header.with_synced_at(issue.updated_at).render + body) - @merge_base.write(header.owner_repo, header.number, body) + # @param path [Pathname] + # @param plan [Dev::Plan::Content] + # @param issue [Dev::Plan::GithubIssues::Issue] + def record_sync(path, plan, issue) + path.write(plan.with_synced_at(issue.updated_at).render) + @merge_base.write(plan.header.owner_repo, plan.header.number, plan.body) end # Move a freshly linked file to the `gh--.plan.md` convention @@ -326,8 +335,8 @@ def extract_title(body) # @return [Pathname] def find_linked_plan(owner_repo, number) @workspace.linked_plan_files.find do |path| - header, _body = Header.split(path.read) - header.owner_repo == owner_repo && header.number == number + plan = Content.parse(path.read) + plan.header.owner_repo == owner_repo && plan.header.number == number end end @@ -343,8 +352,7 @@ def sole_unlinked_plan files = if @workspace.plans_dir.directory? @workspace.plans_dir.glob(Workspace::PLAN_GLOB).sort.reject do |path| - header, _body = Header.split(path.read) - header + Content.parse(path.read).header end else [] diff --git a/lib/dev/plan/content.rb b/lib/dev/plan/content.rb new file mode 100644 index 0000000..61624d2 --- /dev/null +++ b/lib/dev/plan/content.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "dev/plan/header" +require "dev/plan/frontmatter" + +module Dev + module Plan + # The three on-disk layers of a plan file: ai-flow sync header, optional + # Cursor YAML frontmatter, and the markdown body. Sync compares and ships + # the markdown body only; the header and frontmatter stay local. + class Content + # @return [Dev::Plan::Header, nil] + attr_reader :header + + # @return [String, nil] raw frontmatter block including `---` fences + attr_reader :frontmatter + + # @return [String] markdown body (canonical plan prose) + attr_reader :body + + # Parse a plan file into its layers. Canonical on-disk order is header, + # then optional frontmatter, then body. When frontmatter sits above the + # ai-flow header (hand edit), both are still recognized; {#render} + # rewrites canonical order. + # + # @param content [String] + # @return [Content] + def self.parse(content) + header, remainder = Header.split(content) + if header + frontmatter, body = Frontmatter.split(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) + if frontmatter + header, body = Header.split(after_frontmatter) + return new(header: header, frontmatter: frontmatter, body: body) + end + + new(header: nil, frontmatter: nil, body: content) + end + + # @param header [Dev::Plan::Header, nil] + # @param frontmatter [String, nil] + # @param body [String] + def initialize(header:, frontmatter:, body:) + @header = header + @frontmatter = frontmatter + @body = body + end + + # Serialize in canonical order: ai-flow header, optional frontmatter, + # markdown body. + # + # @return [String] + def render + "#{header&.render}#{frontmatter}#{body}" + end + + # @param header [Dev::Plan::Header, nil] + # @return [Content] + def with_header(header) + self.class.new(header: header, frontmatter: frontmatter, body: body) + end + + # @param body [String] + # @return [Content] + def with_body(body) + self.class.new(header: header, frontmatter: frontmatter, body: body) + end + + # @param synced_at [String] + # @return [Content] + def with_synced_at(synced_at) + with_header(header.with_synced_at(synced_at)) + end + end + end +end diff --git a/lib/dev/plan/frontmatter.rb b/lib/dev/plan/frontmatter.rb new file mode 100644 index 0000000..148c597 --- /dev/null +++ b/lib/dev/plan/frontmatter.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "yaml" + +module Dev + module Plan + # Cursor plan YAML frontmatter (`---` … `---`) — local editor state + # (picker label, overview, todos). Peeled before any GitHub sync so the + # issue body stays markdown-only. + class Frontmatter + FENCE_LINE = /\A---\n?\z/ + + # Peel a Cursor-like YAML frontmatter block from the start of +content+. + # Only a leading `---` … `---` fence whose interior is a YAML mapping is + # removed; ordinary markdown horizontal rules deeper in the body, or a + # leading `---` that is not a mapping, are left alone. + # + # @param content [String] + # @return [Array(String | nil, String)] frontmatter block (including + # fences and a trailing newline after the closing fence) or nil, and + # the remainder + def self.split(content) + lines = content.lines + return [nil, content] if lines.empty? || !fence?(lines.fetch(0)) + + close_index = (1...lines.length).find { |index| fence?(lines.fetch(index)) } + return [nil, content] unless close_index + + yaml_text = lines[1...close_index].join + return [nil, content] unless mapping?(yaml_text) + + frontmatter = lines[0..close_index].join + frontmatter = "#{frontmatter}\n" unless frontmatter.end_with?("\n") + body = lines[(close_index + 1)..].join + [frontmatter, body] + end + + # @param line [String] + # @return [Boolean] + def self.fence?(line) + line.match?(FENCE_LINE) + end + private_class_method :fence? + + # @param yaml_text [String] interior between fences + # @return [Boolean] true when the interior parses as a YAML mapping + def self.mapping?(yaml_text) + parsed = YAML.safe_load(yaml_text) + parsed.is_a?(Hash) + rescue Psych::SyntaxError, Psych::DisallowedClass + false + end + private_class_method :mapping? + end + end +end diff --git a/lib/dev/plan/header.rb b/lib/dev/plan/header.rb index 524af5a..ba870f2 100644 --- a/lib/dev/plan/header.rb +++ b/lib/dev/plan/header.rb @@ -4,8 +4,9 @@ module Dev module Plan module_function - # Render a plan body as an issue body: verbatim content, normalized to a - # single trailing newline. + # Normalize a markdown plan body for the issue: LF, single trailing + # newline. Callers pass the markdown body only — not Cursor YAML + # frontmatter or the ai-flow sync header. # # @param plan_body [String] # @return [String] @@ -13,9 +14,10 @@ def to_issue_body(plan_body) "#{plan_body.rstrip}\n" end - # Extract the plan body from an issue body. Issue bodies use CRLF line - # endings when edited via the GitHub web UI, so normalize to LF — the - # local file and merge base always use LF. + # Normalize an issue body to a local markdown plan body. Issue bodies use + # CRLF when edited via the GitHub web UI, so normalize to LF — the local + # file and merge base always use LF. The result is markdown only (GitHub + # never stores Cursor frontmatter). # # @param issue_body [String, nil] # @return [String] diff --git a/lib/dev/plan/workspace.rb b/lib/dev/plan/workspace.rb index fa8a551..9797c1c 100644 --- a/lib/dev/plan/workspace.rb +++ b/lib/dev/plan/workspace.rb @@ -53,8 +53,7 @@ def linked_plan_files return [] unless @plans_dir.directory? @plans_dir.glob(PLAN_GLOB).sort.select do |path| - header, _body = Header.split(path.read) - !header.nil? + !Content.parse(path.read).header.nil? end end diff --git a/share/cursor-skills/ai-flow/SKILL.md b/share/cursor-skills/ai-flow/SKILL.md index 72d8777..eebe84f 100644 --- a/share/cursor-skills/ai-flow/SKILL.md +++ b/share/cursor-skills/ai-flow/SKILL.md @@ -65,7 +65,7 @@ Open feedback lives as issue comments (often quote-anchored). To address it: ## Link format (GitHub is the backend) Plan links use GitHub URL format everywhere — the local plan file included, -since its content becomes the issue body verbatim. Never use local filesystem +since its **markdown body** becomes the issue body. Never use local filesystem paths (`/Users/…`) in a plan. - Files: `https://github.com///blob/HEAD/` (`blob/HEAD` @@ -77,6 +77,42 @@ paths (`/Users/…`) in a plan. - If a plan contains a local path (e.g. from a cmd+L file reference), rewrite it to the formats above while editing. +## Layers of a linked local plan file + +A linked `.plan.md` has up to three layers. Only the markdown body is +canonical on GitHub: + +``` + +--- +name: … +overview: … +todos: […] +isProject: false +--- + +# Plan title + +…markdown body… +``` + +| Layer | On disk | On GitHub | Owner | +|---|---|---|---| +| `` header | yes | no | `dev plan` sync guard | +| YAML frontmatter (`---`…`---`) | yes (optional) | **no** | Cursor plan UI | +| Markdown body | yes | **yes (canonical)** | humans + ai-flow review | + +Cursor YAML frontmatter (`name`, `overview`, `todos`, `isProject`) is local +editor state — a picker label and session checklist. **Do not hand-edit +frontmatter into issue-facing prose**, and do not expect `dev plan push` to +publish it. Sync compares and PATCHes the markdown body only; ticking a todo +or renaming in Cursor must not mark the plan dirty. Absence of frontmatter is +valid (e.g. a fresh `dev plan pull`); Cursor may add it the next time it opens +the file. + ## Conventions (do not violate) - Linked plan files start with an `` HTML comment header diff --git a/test/dev/plan/accessor_test.rb b/test/dev/plan/accessor_test.rb index 975c639..41a1a39 100644 --- a/test/dev/plan/accessor_test.rb +++ b/test/dev/plan/accessor_test.rb @@ -85,9 +85,22 @@ def build_env(dir) end def read_plan(root, name) - Dev::Plan::Header.split((root / ".cursor" / "plans" / name).read) + plan = Dev::Plan::Content.parse((root / ".cursor" / "plans" / name).read) + [plan.header, plan.body, plan.frontmatter] end + CURSOR_FRONTMATTER = <<~YAML + --- + name: Local picker label + overview: Short overview + todos: + - id: step-one + content: Do the thing + status: pending + isProject: false + --- + YAML + test "new creates the issue and a linked local plan" do Given "a workspace" dir = Dir.mktmpdir("ai-flow-acc-test-") @@ -435,4 +448,134 @@ def read_plan(root, name) Cleanup FileUtils.rm_rf(dir) end + + test "link and push ship markdown only — Cursor frontmatter stays local" do + Given "a Cursor draft with YAML frontmatter" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, root, issues = build_env(dir) + draft = root / ".cursor" / "plans" / "draft.plan.md" + FileUtils.mkdir_p(draft.dirname) + draft.write("#{CURSOR_FRONTMATTER}# Squeeze visual\n\nImprove the membrane.\n") + + When "canonizing and pushing" + accessor.run(["link", draft.to_s], out: StringIO.new) + + Then "the issue body is markdown-only and the local file keeps frontmatter" + issues.get(REPO, 1).title == "Squeeze visual" + issues.get(REPO, 1).body == "# Squeeze visual\n\nImprove the membrane.\n" + !issues.get(REPO, 1).body.include?("isProject:") + header, body, frontmatter = read_plan(root, "gh-1-squeeze-visual.plan.md") + header.issue_ref == "#{REPO}#1" + body == "# Squeeze visual\n\nImprove the membrane.\n" + frontmatter == CURSOR_FRONTMATTER + + Cleanup + FileUtils.rm_rf(dir) + end + + test "pull preserves local Cursor frontmatter while replacing the markdown body" do + Given "a linked plan with local frontmatter whose issue moved ahead" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, root, issues = build_env(dir) + accessor.run(["new", "Carve system"], out: StringIO.new) + path = root / ".cursor" / "plans" / "gh-1-carve-system.plan.md" + plan = Dev::Plan::Content.parse(path.read) + path.write(Dev::Plan::Content.new( + header: plan.header, frontmatter: CURSOR_FRONTMATTER, body: plan.body, + ).render) + issues.edit_remotely(REPO, 1, body: "# Carve system\n\nRemote addition.\n") + + When "pulling" + accessor.run(["pull", "1"], out: StringIO.new) + + Then "the markdown matches the remote and frontmatter is untouched" + _header, body, frontmatter = read_plan(root, "gh-1-carve-system.plan.md") + body == "# Carve system\n\nRemote addition.\n" + frontmatter == CURSOR_FRONTMATTER + + Cleanup + FileUtils.rm_rf(dir) + end + + test "frontmatter-only edits leave the plan clean and push is a no-op" do + Given "a linked plan whose only local change is Cursor todos" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, root, issues = build_env(dir) + accessor.run(["new", "Carve system"], out: StringIO.new) + path = root / ".cursor" / "plans" / "gh-1-carve-system.plan.md" + plan = Dev::Plan::Content.parse(path.read) + path.write(Dev::Plan::Content.new( + header: plan.header, frontmatter: CURSOR_FRONTMATTER, body: plan.body, + ).render) + out = StringIO.new + + When "checking status and pushing" + accessor.run(["status"], out: out) + accessor.run(["push"], out: StringIO.new) + + Then "status is clean, the issue is unchanged, and frontmatter remains local" + out.string.match?(/^clean\s+#{REPO}#1/) + issues.get(REPO, 1).body == "# Carve system\n" + _header, body, frontmatter = read_plan(root, "gh-1-carve-system.plan.md") + body == "# Carve system\n" + frontmatter == CURSOR_FRONTMATTER + + Cleanup + FileUtils.rm_rf(dir) + end + + test "pull --merge merges markdown only and keeps local frontmatter" do + Given "a diverged plan with local Cursor frontmatter" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, root, issues = build_env(dir) + base = "# Plan\n\nalpha\n\none\ntwo\nthree\nfour\n\nomega\n" + issues.create(REPO, title: "Plan", body: "#{base}\n") + accessor.run(["pull", "1"], out: StringIO.new) + path = root / ".cursor" / "plans" / "gh-1-plan.plan.md" + plan = Dev::Plan::Content.parse(path.read) + path.write(Dev::Plan::Content.new( + header: plan.header, + frontmatter: CURSOR_FRONTMATTER, + body: base.sub("alpha", "alpha LOCAL"), + ).render) + issues.edit_remotely(REPO, 1, body: "#{base.sub("omega", "omega REMOTE")}\n") + + When "pulling with --merge" + accessor.run(["pull", "1", "--merge"], out: StringIO.new) + + Then "both markdown edits land and frontmatter is preserved" + _header, body, frontmatter = read_plan(root, "gh-1-plan.plan.md") + body == base.sub("alpha", "alpha LOCAL").sub("omega", "omega REMOTE") + frontmatter == CURSOR_FRONTMATTER + + Cleanup + FileUtils.rm_rf(dir) + end + + test "push strips previously published frontmatter from the issue body" do + Given "an issue that still carries Cursor frontmatter from the old sync bug" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, root, issues = build_env(dir) + polluted = "#{CURSOR_FRONTMATTER}# Carve system\n" + issue = issues.create(REPO, title: "Carve system", body: polluted) + path = root / ".cursor" / "plans" / "gh-1-carve-system.plan.md" + FileUtils.mkdir_p(path.dirname) + header = Dev::Plan::Header.new(owner_repo: REPO, number: 1, synced_at: issue.updated_at) + path.write(Dev::Plan::Content.new( + header: header, frontmatter: CURSOR_FRONTMATTER, body: "# Carve system\n", + ).render) + merge_base = Dev::Plan::MergeBase.new(state_dir: File.join(dir, "state")) + merge_base.write(REPO, 1, polluted) + + When "pushing the linked plan" + accessor.run(["push", path.to_s], out: StringIO.new) + + Then "the issue is cleaned to markdown-only" + issues.get(REPO, 1).body == "# Carve system\n" + !issues.get(REPO, 1).body.include?("---\n") + + Cleanup + FileUtils.rm_rf(dir) + end end + diff --git a/test/dev/plan/content_test.rb b/test/dev/plan/content_test.rb new file mode 100644 index 0000000..dad4572 --- /dev/null +++ b/test/dev/plan/content_test.rb @@ -0,0 +1,69 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/plan" + +transform!(RSpock::AST::Transformation) +class Dev::Plan::ContentTest < Minitest::Test + FRONTMATTER = <<~YAML + --- + name: Local label + isProject: false + --- + YAML + + test "parse peels header, frontmatter, and markdown body" do + Given "a linked plan with Cursor frontmatter" + header = Dev::Plan::Header.new(owner_repo: "d3mlabs/demo", number: 1, synced_at: "2026-01-01T00:00:00Z") + body = "# Plan title\n\nprose\n" + raw = "#{header.render}#{FRONTMATTER}#{body}" + + When "parsing it" + plan = Dev::Plan::Content.parse(raw) + + Then "all three layers are separated" + plan.header.issue_ref == "d3mlabs/demo#1" + plan.frontmatter == FRONTMATTER + plan.body == body + + Cleanup + nil + end + + test "render writes canonical header-then-frontmatter-then-body order" do + Given "frontmatter sitting above the ai-flow header" + header = Dev::Plan::Header.new(owner_repo: "d3mlabs/demo", number: 2, synced_at: "2026-01-01T00:00:00Z") + body = "# Title\n" + raw = "#{FRONTMATTER}#{header.render}#{body}" + + When "parsing and re-rendering" + plan = Dev::Plan::Content.parse(raw) + + Then "layers are recognized and render normalizes order" + plan.header.number == 2 + plan.frontmatter == FRONTMATTER + plan.body == body + plan.render == "#{header.render}#{FRONTMATTER}#{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" + raw = "#{FRONTMATTER}#{body}" + + When "parsing it" + plan = Dev::Plan::Content.parse(raw) + + Then "frontmatter is local-only and the body is markdown" + plan.header.nil? + plan.frontmatter == FRONTMATTER + plan.body == body + + Cleanup + nil + end +end diff --git a/test/dev/plan/frontmatter_test.rb b/test/dev/plan/frontmatter_test.rb new file mode 100644 index 0000000..43f5e44 --- /dev/null +++ b/test/dev/plan/frontmatter_test.rb @@ -0,0 +1,96 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/plan" + +transform!(RSpock::AST::Transformation) +class Dev::Plan::FrontmatterTest < Minitest::Test + CURSOR_FRONTMATTER = <<~YAML + --- + name: Organic diapedesis deformation + overview: Improve the squeeze visual + todos: + - id: squeeze-angle + content: Add squeezeAngle to Membrane + status: completed + isProject: false + --- + YAML + + test "split peels a Cursor YAML frontmatter block from the start" do + Given "content with Cursor frontmatter then markdown" + body = "# Plan title\n\nprose\n" + content = "#{CURSOR_FRONTMATTER}#{body}" + + When "splitting it" + frontmatter, remainder = Dev::Plan::Frontmatter.split(content) + + Then "the frontmatter is peeled and the markdown body remains" + frontmatter == CURSOR_FRONTMATTER + remainder == body + + Cleanup + nil + end + + test "split returns nil frontmatter when there is no leading fence" do + Given "plain markdown" + content = "# Just a plan\n\nA horizontal rule deeper down:\n\n---\n\nmore\n" + + When "splitting it" + frontmatter, remainder = Dev::Plan::Frontmatter.split(content) + + Then "nothing is peeled" + frontmatter.nil? + remainder == content + + Cleanup + nil + end + + test "split leaves a leading --- that is not a YAML mapping alone" do + Given "a markdown horizontal rule at the top followed by prose" + content = "---\n\n# Not frontmatter\n" + + When "splitting it" + frontmatter, remainder = Dev::Plan::Frontmatter.split(content) + + Then "the content is untouched" + frontmatter.nil? + remainder == content + + Cleanup + nil + end + + test "split leaves malformed YAML between fences alone" do + Given "fences whose interior is not valid YAML" + content = "---\n: this is broken [\n---\n# Title\n" + + When "splitting it" + frontmatter, remainder = Dev::Plan::Frontmatter.split(content) + + Then "the content is untouched" + frontmatter.nil? + remainder == content + + 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" + + When "splitting it" + frontmatter, remainder = Dev::Plan::Frontmatter.split(content) + + Then "the content is untouched" + frontmatter.nil? + remainder == content + + Cleanup + nil + end +end diff --git a/test/dev/plan/workspace_test.rb b/test/dev/plan/workspace_test.rb index 7bd59a8..be17353 100644 --- a/test/dev/plan/workspace_test.rb +++ b/test/dev/plan/workspace_test.rb @@ -108,6 +108,33 @@ def build_repo(dir, remote: "git@github.com:d3mlabs/demo.git") FileUtils.rm_rf(dir) end + test "linked_plan_files finds a header even when Cursor frontmatter sits above it" do + Given "a hand-edited linked plan with frontmatter above the ai-flow header" + dir = Dir.mktmpdir("ai-flow-ws-test-") + root = build_repo(dir) + plans = root / ".cursor" / "plans" + FileUtils.mkdir_p(plans) + linked = plans / "gh-1-linked.plan.md" + linked.write(<<~PLAN) + --- + name: Local label + isProject: false + --- + + # L + PLAN + workspace = Dev::Plan::Workspace.new(project_root: root) + + Expect + workspace.linked_plan_files == [linked] + + Cleanup + FileUtils.rm_rf(dir) + end + test "slugify bounds length and strips symbols" do Expect Dev::Plan::Workspace.slugify("Héllo, World! ") == "h-llo-world"