@zalom/plastic 2.0.0-alpha.19 → 2.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "ready_set"
7
+ require_relative "roadmap_savepoint"
8
+
9
+ # RoadmapGraph (intent 337, n1): reads one roadmap file into the same shape
10
+ # the node scope already uses - entries with their INDEX-reconciled status,
11
+ # the edge map from "## Graph", the cycle path when one exists, the
12
+ # topological batches, the critical paths, and the ready set. Edges are
13
+ # parsed by GraphEdges; batches, critical paths, and downstream hops come
14
+ # from ReadySet (327 D1, C1: the one parser, the one topological sort - a
15
+ # local copy of either here is exactly the drift 327 forbids). This module
16
+ # writes no file and reads no clock or environment variable (row 1.14);
17
+ # every fact is either a value passed in or read once from the two paths
18
+ # `analyze` is given.
19
+ module RoadmapGraph
20
+ module_function
21
+
22
+ ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+(.*?)[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
23
+ WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
24
+
25
+ INDEX_TAGS = { "Completed" => "delivered", "Abandoned" => "abandoned", "Active" => :active, "Future" => "queued" }.freeze
26
+
27
+ # analyze(roadmap_path, index_path:) -> a Result hash. Never raises across
28
+ # the boundary: a missing ## Graph section, a missing grouping heading, a
29
+ # cyclic graph, or an invalid byte in either file all become a named
30
+ # `reason` or an empty result, never an exception (rows 1.8, 1.9, 1.13).
31
+ def analyze(roadmap_path, index_path: nil)
32
+ text = read_utf8(roadmap_path)
33
+
34
+ graph_section = GraphFile.section_body(text, "## Graph")
35
+ unless graph_section
36
+ return result(has_graph: false, reason: "no ## Graph section in #{roadmap_path}")
37
+ end
38
+
39
+ parsed_edges = GraphEdges.parse(GraphFile.strip_fenced_blocks(graph_section))
40
+
41
+ entries_by_id, order = begin
42
+ parse_entries(text, roadmap_path)
43
+ rescue RoadmapSavepoint::MissingGroupingHeading => e
44
+ return result(has_graph: true, reason: e.message)
45
+ end
46
+
47
+ index_map = load_index(index_path)
48
+ entries_by_id.each_value { |e| e[:status] = reconcile_status(index_map[e[:id]], e[:raw_status]) }
49
+
50
+ declared_and_targeted = parsed_edges[:nodes]
51
+ entry_ids = order.dup
52
+
53
+ # 1.6: an entry the graph never named (not declared, not targeted) is
54
+ # folded in as a root, so a partly migrated roadmap never silently
55
+ # drops real work from the batches.
56
+ edges = parsed_edges[:edges].dup
57
+ entry_ids.each do |id|
58
+ next if declared_and_targeted.include?(id)
59
+
60
+ edges[id] = []
61
+ entries_by_id[id][:in_graph] = false
62
+ end
63
+ entry_ids.each do |id|
64
+ entries_by_id[id][:in_graph] = true if declared_and_targeted.include?(id) && entries_by_id[id][:in_graph].nil?
65
+ end
66
+
67
+ # 1.7: a graph id with no batch entry is named in `dangling`, never
68
+ # invented as a fake entry with no title.
69
+ dangling = declared_and_targeted.reject { |id| entries_by_id.key?(id) }
70
+
71
+ cyc = GraphEdges.cycle(edges)
72
+ if cyc
73
+ return result(has_graph: true, entries: entries_by_id, edges: edges, dangling: dangling,
74
+ cycle: cyc, errors: parsed_edges[:errors])
75
+ end
76
+
77
+ batch_result = ReadySet.batches(edges)
78
+ batches = batch_result[:ok] ? order_batches(batch_result[:batches], order) : []
79
+ path_result = ReadySet.critical_paths(edges)
80
+
81
+ status_map = entries_by_id.each_with_object({}) { |(id, e), h| h[id] = e[:status] }
82
+ dead_ends = entry_ids.select do |id|
83
+ entries_by_id[id][:status] != "delivered" && ReadySet.dead_end?(id, edges, status_map)
84
+ end
85
+
86
+ ready = entry_ids.select do |id|
87
+ entries_by_id[id][:status] == "queued" &&
88
+ (edges[id] || []).all? { |t| status_map[t] == "delivered" }
89
+ end
90
+
91
+ result(
92
+ has_graph: true,
93
+ entries: entries_by_id,
94
+ edges: edges,
95
+ dangling: dangling,
96
+ cycle: nil,
97
+ batches: batches,
98
+ batches_error: batch_result[:ok] ? nil : batch_result[:error],
99
+ critical_paths: path_result[:ok] ? path_result : nil,
100
+ dead_ends: dead_ends,
101
+ ready: ready,
102
+ errors: parsed_edges[:errors]
103
+ )
104
+ end
105
+
106
+ # --- shared building blocks other callers (RoadmapQueue, n9) reuse --------
107
+
108
+ # The one place that knows how a roadmap's own "## Graph" section is read:
109
+ # an exact heading (GraphFile), fenced examples stripped, and an edgeless
110
+ # section treated the same as no section at all. Returns GraphEdges.parse's
111
+ # own Result hash, or nil.
112
+ def parse_graph_section(text)
113
+ section = GraphFile.section_body(text, "## Graph")
114
+ return nil if section.nil?
115
+
116
+ parsed = GraphEdges.parse(GraphFile.strip_fenced_blocks(section))
117
+ return nil if parsed[:nodes].empty?
118
+
119
+ parsed
120
+ end
121
+
122
+ # ReadySet.batches's own topological layers (the one sort, D1), reordered
123
+ # WITHIN each layer to match `order` - typically the roadmap file's own
124
+ # entry-encounter order (row 1.16/9.8). A computed batch names which layer
125
+ # an id is in; it must never silently become a ranking decision by
126
+ # defaulting to a lexical sort of ids that happen to share a layer.
127
+ def order_batches(batches, order)
128
+ rank = {}
129
+ order.each_with_index { |id, i| rank[id] = i }
130
+ batches.map { |layer| layer.sort_by { |id| [rank[id] || order.length, id] } }
131
+ end
132
+
133
+ # --- entries -----------------------------------------------------------------
134
+
135
+ def parse_entries(text, roadmap_path)
136
+ body = RoadmapSavepoint.grouping_section_body(text, path: roadmap_path)
137
+ entries = {}
138
+ order = []
139
+ body.each_line do |line|
140
+ stripped = line.chomp.strip
141
+ m = stripped.match(ENTRY)
142
+ next unless m
143
+
144
+ id = m[2]
145
+ next if entries.key?(id)
146
+
147
+ entries[id] = { id: id, title: m[3].strip, raw_status: m[4].downcase, in_graph: nil }
148
+ order << id
149
+ end
150
+ [entries, order]
151
+ end
152
+
153
+ def reconcile_status(tag, raw_status)
154
+ case tag
155
+ when "delivered" then "delivered"
156
+ when "abandoned" then "abandoned"
157
+ when "queued" then "queued"
158
+ when :active then raw_status == "delivered" ? "delivering" : raw_status
159
+ else raw_status
160
+ end
161
+ end
162
+
163
+ def load_index(index_path)
164
+ map = {}
165
+ return map unless index_path && File.exist?(index_path)
166
+
167
+ text = read_utf8(index_path)
168
+ INDEX_TAGS.each do |heading, tag|
169
+ section_body(text, heading).each_line do |line|
170
+ stripped = line.strip
171
+ next unless stripped.start_with?("- [")
172
+
173
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
174
+ map[m[1]] = tag if m
175
+ end
176
+ end
177
+ map
178
+ end
179
+
180
+ def section_body(text, heading)
181
+ m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
182
+ m ? m[1] : ""
183
+ end
184
+
185
+ # 1.13: an invalid byte in either file is scrubbed, never raised across
186
+ # the boundary.
187
+ def read_utf8(path)
188
+ text = File.read(path)
189
+ text.force_encoding(Encoding::UTF_8)
190
+ text.valid_encoding? ? text : text.scrub("")
191
+ end
192
+
193
+ def result(has_graph:, reason: nil, entries: {}, edges: {}, dangling: [], cycle: nil,
194
+ batches: [], batches_error: nil, critical_paths: nil, dead_ends: [], ready: [], errors: [])
195
+ {
196
+ has_graph: has_graph,
197
+ reason: reason,
198
+ entries: entries,
199
+ edges: edges,
200
+ dangling: dangling,
201
+ cycle: cycle,
202
+ batches: batches,
203
+ batches_error: batches_error,
204
+ critical_paths: critical_paths,
205
+ dead_ends: dead_ends,
206
+ ready: ready,
207
+ errors: errors,
208
+ }
209
+ end
210
+ end
@@ -0,0 +1,95 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "roadmap_graph"
5
+ require_relative "roadmap_render"
6
+ require_relative "roadmap_savepoint"
7
+ require_relative "graph_edges"
8
+ require_relative "graph_file"
9
+ require_relative "atomic_write"
10
+
11
+ # RoadmapMigration (intent 337, n4): reads a graphless roadmap's existing
12
+ # batch order and returns the conservative edge set that preserves it -
13
+ # every entry of batch N needs every entry of batch N-1, batch 1 needs
14
+ # nothing. Never overwrites a roadmap that already carries a "## Graph" (or
15
+ # graph-like, row 4.15) heading: the owner's hand-edited edges are the only
16
+ # place a cross-intent edge lives, and a derived guess must never replace
17
+ # them. Block splitting and entry-id extraction are the same surgical
18
+ # parser RoadmapRender uses (n3) - never a second one.
19
+ module RoadmapMigration
20
+ module_function
21
+
22
+ GRAPH_HEADING_RE = /\A##\s+Graph\b/.freeze
23
+
24
+ # {ok:, skipped:, edges:, reason:}. Computes only; writes nothing.
25
+ def derive(path, index_path: nil)
26
+ text = File.read(path)
27
+
28
+ if text.each_line.any? { |l| l.strip.match?(GRAPH_HEADING_RE) }
29
+ return { ok: false, skipped: true, edges: {}, reason: "already carries a ## Graph (or graph-like) heading, left untouched" }
30
+ end
31
+
32
+ begin
33
+ body = RoadmapSavepoint.grouping_section_body(text, path: path)
34
+ rescue RoadmapSavepoint::MissingGroupingHeading => e
35
+ return { ok: false, skipped: false, edges: {}, reason: e.message }
36
+ end
37
+
38
+ _preamble, blocks = RoadmapRender.split_into_blocks(body)
39
+ batch_ids = blocks.map { |b| b[:lines].filter_map { |l| RoadmapRender.entry_id(l) } }
40
+
41
+ entries_by_id = entry_statuses(text, path, index_path)
42
+
43
+ edges = {}
44
+ batch_ids.each_with_index do |ids, i|
45
+ prev_ids = i.zero? ? [] : batch_ids[i - 1].reject { |id| abandoned?(entries_by_id, id) }
46
+ # An id repeated across two batches (row 4.16) must never need itself:
47
+ # exclude it from its own prev_ids before assigning.
48
+ ids.each { |id| edges[id] = prev_ids.reject { |p| p == id } }
49
+ end
50
+
51
+ cyc = GraphEdges.cycle(edges)
52
+ return { ok: false, skipped: false, edges: {}, reason: "derived graph is cyclic: #{cyc.join(' > ')}" } if cyc
53
+
54
+ { ok: true, skipped: false, edges: edges, reason: nil }
55
+ end
56
+
57
+ # derive + render "## Graph" through AtomicWrite. A skip (row 4.3) or a
58
+ # refusal (cyclic, row 4.5) writes nothing and reports why.
59
+ def write(path, renamer: File.method(:rename), dry_run: false, index_path: nil)
60
+ result = derive(path, index_path: index_path)
61
+ return { ok: result[:ok], written: false, content: nil, skipped: result[:skipped], reason: result[:reason] } unless result[:ok]
62
+
63
+ graph_text = render_edges(result[:edges])
64
+ original = File.read(path)
65
+ content = GraphFile.replace_or_append_section(original, "## Graph", graph_text)
66
+
67
+ if dry_run
68
+ { ok: true, written: false, content: content, skipped: false, reason: nil }
69
+ else
70
+ AtomicWrite.write(path, content, renamer: renamer)
71
+ { ok: true, written: true, content: content, skipped: false, reason: nil }
72
+ end
73
+ end
74
+
75
+ def render_edges(edges)
76
+ lines = edges.map do |id, needs|
77
+ "- #{id} needs #{needs.empty? ? 'nothing' : needs.join(' ')}\n"
78
+ end
79
+ lines.join
80
+ end
81
+
82
+ def entry_statuses(text, path, index_path)
83
+ entries_by_id, = RoadmapGraph.parse_entries(text, path)
84
+ resolved_index = index_path || RoadmapRender.default_index_path(path)
85
+ index_map = RoadmapGraph.load_index(resolved_index)
86
+ entries_by_id.each_value { |e| e[:status] = RoadmapGraph.reconcile_status(index_map[e[:id]], e[:raw_status]) }
87
+ entries_by_id
88
+ rescue RoadmapSavepoint::MissingGroupingHeading
89
+ {}
90
+ end
91
+
92
+ def abandoned?(entries_by_id, id)
93
+ entries_by_id[id] && entries_by_id[id][:status] == "abandoned"
94
+ end
95
+ end
@@ -6,6 +6,8 @@ require "json"
6
6
  require_relative "roadmap_savepoint"
7
7
  require_relative "graph_file"
8
8
  require_relative "graph_edges"
9
+ require_relative "ready_set"
10
+ require_relative "roadmap_graph"
9
11
 
10
12
  # FileOrderRanker - the default value-ordering strategy: today's roadmap file order,
11
13
  # unchanged. This is the intent-173 ranking-swap seam (sibling to the 147 DB-swap seam): a
@@ -143,27 +145,17 @@ class RoadmapQueue
143
145
  {
144
146
  slug: File.basename(path, ".md"), path: path,
145
147
  waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)),
146
- graph_edges: parse_roadmap_graph(text),
148
+ # D15: an exact "## Graph" heading line only, never a prefix - a live
149
+ # roadmap can carry "## Graph (2026-09-01, superseded by ...)". A
150
+ # section that yields no real edge lines is treated the same as no
151
+ # section at all: the fallback to wave order, both silent (D15).
152
+ # Fence-aware (D18). Intent 337 n9: reads through the shared model
153
+ # (RoadmapGraph.parse_graph_section) rather than a second local
154
+ # parser, so this heading/fence/emptiness rule is made in one place.
155
+ graph_edges: RoadmapGraph.parse_graph_section(text),
147
156
  }
148
157
  end
149
158
 
150
- # D15: an exact "## Graph" heading line only, never a prefix - a live
151
- # roadmap can carry "## Graph (2026-09-01, superseded by ...)", which
152
- # GraphFile.section_body already treats as a non-match because it locates
153
- # a section by an exact stripped-line comparison. A section that yields no
154
- # real edge lines (GraphEdges.parse finds zero nodes) is treated the same
155
- # as no section at all: the fallback to wave order, both silent (D15).
156
- # Fence-aware (D18): a fenced example edge line is never read as real.
157
- def parse_roadmap_graph(text)
158
- section = GraphFile.section_body(text, "## Graph")
159
- return nil if section.nil?
160
-
161
- parsed = GraphEdges.parse(GraphFile.strip_fenced_blocks(section))
162
- return nil if parsed[:nodes].empty?
163
-
164
- parsed
165
- end
166
-
167
159
  def parse_waves(waves_body)
168
160
  waves = []
169
161
  current = nil
@@ -329,7 +321,13 @@ class RoadmapQueue
329
321
 
330
322
  (id_to_entry.keys - all_graph_nodes(edges)).each { |id| edges[id] = [] }
331
323
 
332
- topological_layers(edges).each do |layer|
324
+ # Intent 337 n9: the one topological sort (327 D1) is ReadySet.batches;
325
+ # RoadmapGraph.order_batches only reorders WITHIN a layer to match the
326
+ # roadmap file's own entry order (row 9.8), never a second sort.
327
+ batch_result = ReadySet.batches(edges)
328
+ layers = batch_result[:ok] ? RoadmapGraph.order_batches(batch_result[:batches], id_to_entry.keys) : []
329
+
330
+ layers.each do |layer|
333
331
  layer_ids = layer.select { |id| id_to_entry.key?(id) }
334
332
  next if layer_ids.empty?
335
333
 
@@ -356,29 +354,6 @@ class RoadmapQueue
356
354
  nil
357
355
  end
358
356
 
359
- # Topological layers of `edges` ({id => [needs...]}): layer one is every
360
- # id needing nothing, layer k is every id all of whose needs sit in layers
361
- # below k. Never called on a cyclic graph (the caller checks first), so no
362
- # cycle guard is needed here.
363
- def topological_layers(edges)
364
- nodes = edges.keys.dup
365
- edges.each_value { |targets| (targets || []).each { |t| nodes << t unless nodes.include?(t) } }
366
-
367
- layer = {}
368
- assign = nil
369
- assign = lambda do |node|
370
- next layer[node] if layer.key?(node)
371
-
372
- needs = edges[node] || []
373
- layer[node] = needs.empty? ? 1 : 1 + needs.map { |t| assign.call(t) }.max
374
- end
375
- nodes.each { |n| assign.call(n) }
376
-
377
- grouped = Hash.new { |h, k| h[k] = [] }
378
- layer.each { |n, l| grouped[l] << n }
379
- grouped.keys.sort.map { |l| grouped[l].sort }
380
- end
381
-
382
357
  def blocked_for(candidate)
383
358
  explicit = candidate[:waves].flat_map do |wave|
384
359
  wave[:entries].select { |e| e[:status] == "blocked" }.map do |e|
@@ -0,0 +1,150 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "roadmap_graph"
5
+ require_relative "roadmap_savepoint"
6
+ require_relative "graph_file"
7
+ require_relative "graph_tree"
8
+ require_relative "atomic_write"
9
+
10
+ # RoadmapRender (intent 337, n3): renders "## Tree" and the roadmap's own
11
+ # grouping section (## Batches, or legacy ## Waves, owner ruling 145 -
12
+ # never renamed) from the RoadmapGraph model and writes both back through
13
+ # AtomicWrite, replacing exactly those two sections and leaving every other
14
+ # byte of the file alone.
15
+ #
16
+ # The regroup is surgical, never a rebuild from parsed fields (327's own
17
+ # lesson, folded at the 2026-09-10 plan review, rows 3.2/3.13/3.14): only
18
+ # entry lines RoadmapGraph itself recognized (the canonical checkbox +
19
+ # status grammar) ever move. A line the grammar cannot parse, and any prose
20
+ # a human wrote between batch headings, is untouched OUTPUT text - it is
21
+ # never even looked at, so it can never be lost. An id that already sits in
22
+ # its computed batch's heading block is never rewritten; only an id that
23
+ # needs to move is removed from its old block and appended, verbatim, to
24
+ # its new one. This is what makes a no-op render byte-identical (row 3.9)
25
+ # and a genuinely moved entry keep its own exact text (row 3.2).
26
+ module RoadmapRender
27
+ module_function
28
+
29
+ TREE_WIDTH = 100
30
+
31
+ def write(path, renamer: File.method(:rename), dry_run: false, index_path: nil)
32
+ original = File.read(path)
33
+ resolved_index = index_path || default_index_path(path)
34
+
35
+ analysis = RoadmapGraph.analyze(path, index_path: resolved_index)
36
+ return refusal(analysis[:reason]) if analysis[:reason]
37
+ return refusal("cyclic graph, refusing to render: #{analysis[:cycle].join(' > ')}") if analysis[:cycle]
38
+
39
+ tree_result = render_tree(analysis)
40
+ return refusal(tree_result[:error]) unless tree_result[:ok]
41
+
42
+ content = GraphFile.replace_or_append_section(original, "## Tree", tree_result[:text])
43
+
44
+ heading = RoadmapSavepoint.grouping_heading(original)
45
+ return refusal("neither ## Batches nor ## Waves grouping heading found") unless heading
46
+
47
+ heading_text = "## #{heading}"
48
+ original_body = RoadmapSavepoint.grouping_section_body(original, path: path)
49
+ regrouped = regroup(original_body, analysis)
50
+ content = GraphFile.replace_or_append_section(content, heading_text, regrouped)
51
+
52
+ if dry_run
53
+ { ok: true, content: content, written: false, error: nil }
54
+ else
55
+ AtomicWrite.write(path, content, renamer: renamer)
56
+ { ok: true, content: content, written: true, error: nil }
57
+ end
58
+ end
59
+
60
+ def refusal(reason)
61
+ { ok: false, content: nil, written: false, error: reason }
62
+ end
63
+
64
+ def default_index_path(roadmap_path)
65
+ File.join(File.dirname(File.dirname(roadmap_path)), "INDEX.md")
66
+ end
67
+
68
+ def render_tree(analysis)
69
+ labels = analysis[:entries].each_with_object({}) { |(id, e), h| h[id] = e[:title] }
70
+ marks = {
71
+ critical_path: analysis[:critical_paths] ? (analysis[:critical_paths][:critical_path] || []) : [],
72
+ ready: analysis[:ready] || [],
73
+ }
74
+ GraphTree.render(edges: analysis[:edges], labels: labels, marks: marks, width: TREE_WIDTH)
75
+ end
76
+
77
+ # --- the surgical regroup ------------------------------------------------------
78
+
79
+ ENTRY_ID_RE = /\A-\s*\[([ xX])\]\s+(\S+)\b/.freeze
80
+ HEADING_RE = /\A###\s+/.freeze
81
+
82
+ def regroup(body, analysis)
83
+ preamble, blocks = split_into_blocks(body)
84
+
85
+ real_batches = (analysis[:batches] || []).map { |layer| layer & analysis[:entries].keys }
86
+ original_line_for = build_original_line_index(blocks)
87
+
88
+ total = [blocks.length, real_batches.length].max
89
+ rendered_blocks = (0...total).map do |i|
90
+ block = blocks[i]
91
+ computed_ids = real_batches[i] || []
92
+
93
+ existing_ids = block ? block[:lines].filter_map { |l| entry_id(l) } : []
94
+ removed = existing_ids - computed_ids
95
+ added = computed_ids - existing_ids
96
+
97
+ heading_line = block ? block[:heading] : "### Batch #{i + 1}\n"
98
+ lines = block ? block[:lines].reject { |l| removed.include?(entry_id(l)) } : []
99
+ lines += added.filter_map { |id| original_line_for[id] }
100
+
101
+ heading_line + lines.join
102
+ end
103
+
104
+ # GraphFile.replace_or_append_section always adds its own single blank
105
+ # line before a non-empty tail; a body that already ends in blank lines
106
+ # (round-tripped from a previous render) would otherwise grow by one
107
+ # blank line on every render (row 3.9's byte-identical requirement).
108
+ (preamble.join + rendered_blocks.join).rstrip + "\n"
109
+ end
110
+
111
+ def entry_id(line)
112
+ m = line.match(ENTRY_ID_RE)
113
+ m && m[2]
114
+ end
115
+
116
+ def build_original_line_index(blocks)
117
+ index = {}
118
+ blocks.each do |block|
119
+ block[:lines].each do |line|
120
+ id = entry_id(line)
121
+ index[id] ||= line if id
122
+ end
123
+ end
124
+ index
125
+ end
126
+
127
+ # [preamble_lines, blocks] - blocks is [{heading:, lines: []}, ...] in file
128
+ # order. Every original byte is accounted for: it lives either in the
129
+ # preamble (before the first "### " heading) or in exactly one block's
130
+ # heading/lines.
131
+ def split_into_blocks(body)
132
+ preamble = []
133
+ blocks = []
134
+ current = nil
135
+
136
+ body.each_line do |line|
137
+ if line.match?(HEADING_RE)
138
+ blocks << current if current
139
+ current = { heading: line, lines: [] }
140
+ elsif current
141
+ current[:lines] << line
142
+ else
143
+ preamble << line
144
+ end
145
+ end
146
+ blocks << current if current
147
+
148
+ [preamble, blocks]
149
+ end
150
+ end