@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.
- package/package.json +1 -1
- package/scripts/doctor.rb +36 -0
- package/scripts/index-projection +74 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +44 -0
- package/scripts/lib/node_packet.rb +15 -2
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/report_screen.rb +26 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +17 -42
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/runner +392 -0
- package/skills/auto/SKILL.md +1 -1
- package/skills/roadmap/SKILL.md +17 -0
- package/templates/report-roadmap-plan.md +1 -1
- package/templates/roadmap.md +13 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|