@zalom/plastic 2.0.0-alpha.18 → 2.0.0-alpha.19
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 +2 -2
- package/scripts/dashboard.rb +20 -0
- package/scripts/doctor.rb +120 -2
- package/scripts/end-intent +134 -8
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/installer_core.rb +32 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +873 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +122 -12
- package/scripts/lib/roadmap_queue.rb +161 -3
- package/scripts/lib/roadmap_savepoint.rb +26 -5
- package/scripts/lib/savepoint.rb +123 -12
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +8 -1
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/validate-work-graph +39 -0
- package/skills/auto/SKILL.md +2 -3
- package/skills/auto/references/human-report-contract.md +3 -2
- package/skills/intent-continuing/references/boarding-matrix.md +1 -0
- package/skills/intent-ending/SKILL.md +30 -19
- package/skills/intent-executing/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/templates/graph.md +16 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +8 -6
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_file"
|
|
5
|
+
require_relative "graph_edges"
|
|
6
|
+
require_relative "node_file"
|
|
7
|
+
require_relative "savepoint"
|
|
8
|
+
require_relative "work_graph_validator"
|
|
9
|
+
|
|
10
|
+
# ActionGraphShim (intent 342, G9): a read-time backward shim that presents
|
|
11
|
+
# any intent directory's actions/*.md as a node graph, in the record shape
|
|
12
|
+
# NodeFile.parse returns, so 336, 338 and 339 read a legacy intent through
|
|
13
|
+
# the same call they already use for an authored graph.md (spec.md D1-D18).
|
|
14
|
+
#
|
|
15
|
+
# Writes nothing, anywhere, ever (D1). An authored graph.md always wins over
|
|
16
|
+
# the synthetic chain (D3) - the reverse of 334's forward shim, which
|
|
17
|
+
# resolves content and prefers the older, richer source. This one resolves
|
|
18
|
+
# structure, where an authored graph is the only real graph.
|
|
19
|
+
module ActionGraphShim
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# D10's heading test: unchanged, four tokens, verified correct on the live
|
|
23
|
+
# store. Governs only whether a "## Files ..." heading itself is negated
|
|
24
|
+
# (e.g. "## Files you must NOT change"), never the section body.
|
|
25
|
+
FILES_NEGATION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't/i.freeze
|
|
26
|
+
|
|
27
|
+
# D19's body-truncation vocabulary: the four heading tokens plus the
|
|
28
|
+
# exclusion phrases the live corpus actually uses ("Out of bounds, owned
|
|
29
|
+
# by leads running in parallel right now:" matched none of the original
|
|
30
|
+
# four). Governs whether a body LINE truncates the files harvest -
|
|
31
|
+
# never the heading test above, which stays on FILES_NEGATION_RE.
|
|
32
|
+
FILES_EXCLUSION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't|\bout of bounds\b|\bout of scope\b|\bhands off\b|\bleave alone\b|\bexcluded\b/i.freeze
|
|
33
|
+
|
|
34
|
+
BACKTICK_RE = /`([^`]+)`/.freeze
|
|
35
|
+
PROVEN_BY_LABEL_RE = /\AS\d+\z/.freeze
|
|
36
|
+
|
|
37
|
+
# :authored when graph.md exists, :actions when it does not but the intent
|
|
38
|
+
# holds at least one real action file (D7's exact realness test, reused
|
|
39
|
+
# rather than reimplemented so the shim can never disagree with
|
|
40
|
+
# Savepoint.has_real_action? about whether an intent has work in it), :none
|
|
41
|
+
# otherwise. A directory that does not exist returns :none and never
|
|
42
|
+
# raises.
|
|
43
|
+
def shape(intent_dir)
|
|
44
|
+
return :none unless intent_dir && File.directory?(intent_dir.to_s)
|
|
45
|
+
return :authored if File.exist?(File.join(intent_dir, "graph.md"))
|
|
46
|
+
return :actions if Savepoint.has_real_files_in?("actions", intent_dir)
|
|
47
|
+
|
|
48
|
+
:none
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The seven-key hash GraphFile.parse returns (ok:, goal:, decisions:,
|
|
52
|
+
# graph:, status:, verify:, errors:), for whichever shape the directory
|
|
53
|
+
# is in. On :authored this is literally GraphFile.parse, unmodified (D3).
|
|
54
|
+
# On :actions the same hash is built from the action files, with goal:,
|
|
55
|
+
# decisions: and status: nil (a 2024 action file was never asked for
|
|
56
|
+
# them) and verify: a fixed reason so a trivial-bar check the caller may
|
|
57
|
+
# run stays self-consistent. On :none it is GraphFile.parse's own
|
|
58
|
+
# not-found failure, produced by delegating to it rather than
|
|
59
|
+
# reformatting the message ourselves.
|
|
60
|
+
def view(intent_dir)
|
|
61
|
+
case shape(intent_dir)
|
|
62
|
+
when :authored
|
|
63
|
+
GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
64
|
+
when :actions
|
|
65
|
+
synth = synthetic_graph(intent_dir)
|
|
66
|
+
{
|
|
67
|
+
ok: synth[:errors].empty?,
|
|
68
|
+
goal: nil,
|
|
69
|
+
decisions: nil,
|
|
70
|
+
graph: synth,
|
|
71
|
+
status: nil,
|
|
72
|
+
verify: { reason: "backward shim: legacy actions/ carry no verify node" },
|
|
73
|
+
errors: synth[:errors],
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
# 342 post-execution review, should-fix 5: a nil intent_dir must never
|
|
77
|
+
# raise. shape(nil) is already nil-guarded, so `view` and `needs`
|
|
78
|
+
# honor the same contract rather than raising a File.join TypeError.
|
|
79
|
+
return GraphFile.failure(["graph file not found: #{intent_dir.inspect}"]) unless intent_dir
|
|
80
|
+
|
|
81
|
+
GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Node records for either shape (D8), always carrying NodeFile.parse's own
|
|
86
|
+
# key set (ok:, node:, kind:, files:, budget:, body:, errors:) plus
|
|
87
|
+
# needs:, path: and proven_by:. [] on :none, never a raise on a real
|
|
88
|
+
# directory.
|
|
89
|
+
def nodes(intent_dir)
|
|
90
|
+
case shape(intent_dir)
|
|
91
|
+
when :authored
|
|
92
|
+
authored_nodes(intent_dir)
|
|
93
|
+
when :actions
|
|
94
|
+
synthetic_nodes(intent_dir)
|
|
95
|
+
else
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The needs targets for one node id, under either shape; [] for an id the
|
|
101
|
+
# graph does not declare, and [] for a directory with no graph at all.
|
|
102
|
+
def needs(intent_dir, node_id)
|
|
103
|
+
graph = view(intent_dir)[:graph]
|
|
104
|
+
return [] unless graph
|
|
105
|
+
|
|
106
|
+
(graph[:edges] || {})[node_id] || []
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- authored shape -------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def authored_nodes(intent_dir)
|
|
112
|
+
parsed = GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
113
|
+
edges = parsed.dig(:graph, :edges) || {}
|
|
114
|
+
|
|
115
|
+
# 342 post-execution review, nit 8: sort_key numerically over the
|
|
116
|
+
# basename, the same key the synthetic chain uses, so an authored
|
|
117
|
+
# intent with ten or more nodes orders n10 after n9 rather than
|
|
118
|
+
# lexically between n1 and n2.
|
|
119
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md"))
|
|
120
|
+
.sort_by { |path| sort_key(File.basename(path)) }
|
|
121
|
+
.map do |path|
|
|
122
|
+
nf = NodeFile.parse(path)
|
|
123
|
+
nf.merge(
|
|
124
|
+
needs: edges[nf[:node]] || [],
|
|
125
|
+
path: path,
|
|
126
|
+
proven_by: proven_by_labels(nf[:body].to_s)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- actions shape ----------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# {nodes:, edges:, errors:} built directly over the minted chain, in the
|
|
134
|
+
# same shape GraphEdges.parse returns, rather than rendering a "## Graph"
|
|
135
|
+
# section and reparsing it.
|
|
136
|
+
def synthetic_graph(intent_dir)
|
|
137
|
+
ids = real_action_files(intent_dir).each_index.map { |i| "n#{i + 1}" }
|
|
138
|
+
edges = {}
|
|
139
|
+
ids.each_with_index { |id, i| edges[id] = i.zero? ? [] : [ids[i - 1]] }
|
|
140
|
+
{ nodes: ids, edges: edges, errors: [] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def synthetic_nodes(intent_dir)
|
|
144
|
+
files = real_action_files(intent_dir)
|
|
145
|
+
files.each_with_index.map do |path, i|
|
|
146
|
+
id = "n#{i + 1}"
|
|
147
|
+
needs_targets = i.zero? ? [] : ["n#{i}"]
|
|
148
|
+
begin
|
|
149
|
+
text = File.read(path)
|
|
150
|
+
{
|
|
151
|
+
ok: true,
|
|
152
|
+
node: id,
|
|
153
|
+
kind: "work",
|
|
154
|
+
files: files_section_paths(text),
|
|
155
|
+
budget: nil,
|
|
156
|
+
body: text,
|
|
157
|
+
errors: [],
|
|
158
|
+
needs: needs_targets,
|
|
159
|
+
path: path,
|
|
160
|
+
proven_by: proven_by_labels(text),
|
|
161
|
+
}
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
{
|
|
164
|
+
ok: false,
|
|
165
|
+
node: id,
|
|
166
|
+
kind: "work",
|
|
167
|
+
files: [],
|
|
168
|
+
budget: nil,
|
|
169
|
+
body: nil,
|
|
170
|
+
errors: ["could not read #{path}: #{e.message}"],
|
|
171
|
+
needs: needs_targets,
|
|
172
|
+
path: path,
|
|
173
|
+
proven_by: [],
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Real action files (File.file?, non-zero size, Savepoint.stage_file_present?
|
|
180
|
+
# - D7's own three-part predicate, applied per file since has_real_files_in?
|
|
181
|
+
# only answers true/false), ordered by the integer trailing the basename,
|
|
182
|
+
# falling back to the basename itself so the key is a total order over
|
|
183
|
+
# every basename shape the live store holds (D4).
|
|
184
|
+
def real_action_files(intent_dir)
|
|
185
|
+
Dir.glob(File.join(intent_dir.to_s, "actions", "*.md"))
|
|
186
|
+
.select { |f| File.file?(f) && File.size(f) > 0 && Savepoint.stage_file_present?(f) }
|
|
187
|
+
.sort_by { |f| sort_key(File.basename(f)) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def sort_key(basename)
|
|
191
|
+
m = basename.match(/\d+/)
|
|
192
|
+
m ? [0, m[0].to_i, basename] : [1, 0, basename]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The first heading whose leading token is "Files" (case-insensitively),
|
|
196
|
+
# unless the heading text carries a negation, in which case it is not a
|
|
197
|
+
# files section and the search continues (D10). Paths come out of that
|
|
198
|
+
# section's BODY through extract_files_paths, which is itself
|
|
199
|
+
# negation-aware and fence-aware (D17, D18). No qualifying heading gives
|
|
200
|
+
# [] (D9).
|
|
201
|
+
def files_section_paths(text)
|
|
202
|
+
NodeFile.split_by_headings(text).each do |heading, section|
|
|
203
|
+
next unless files_heading?(heading)
|
|
204
|
+
|
|
205
|
+
return extract_files_paths(section.to_s)
|
|
206
|
+
end
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Fence-aware (D18) and negation-aware over the section body, not only
|
|
211
|
+
# its heading (D17). D10 already stops a heading like "## Files you must
|
|
212
|
+
# NOT change" from being read as a files section at all; this handles
|
|
213
|
+
# the equally common case where a legitimate "## Files to touch" heading
|
|
214
|
+
# is followed, inside the same section, by an out-of-bounds paragraph -
|
|
215
|
+
# live on this intent's own dogfood fixture. A fenced block is dropped
|
|
216
|
+
# before anything else runs, reusing NodeFile.each_fence_line rather than
|
|
217
|
+
# a bare backtick scan, which is exactly what that helper exists to
|
|
218
|
+
# prevent.
|
|
219
|
+
#
|
|
220
|
+
# Lines are scanned in document order; harvesting stops at the first line
|
|
221
|
+
# an exclusion GOVERNS (D19), not merely mentions - a negation is a false
|
|
222
|
+
# positive as often as a real exclusion clause, since an action file
|
|
223
|
+
# routinely annotates an in-bounds path with a "do NOT rename it" aside.
|
|
224
|
+
# governs_exclusion? draws the line: the match governs when it begins
|
|
225
|
+
# before the line's first backtick (an exclusion clause introducing
|
|
226
|
+
# paths) or when the line carries no backtick at all (a bare warning
|
|
227
|
+
# sentence); it annotates, and the line is kept, when the match falls
|
|
228
|
+
# after the first backtick (a parenthetical about a path already named).
|
|
229
|
+
# Any span that still holds a newline is rejected as a backstop: a path
|
|
230
|
+
# is never more than one line.
|
|
231
|
+
def extract_files_paths(section)
|
|
232
|
+
kept = +""
|
|
233
|
+
NodeFile.each_fence_line(section) do |line, fenced|
|
|
234
|
+
next if fenced
|
|
235
|
+
break if governs_exclusion?(line)
|
|
236
|
+
|
|
237
|
+
kept << line
|
|
238
|
+
end
|
|
239
|
+
kept.scan(BACKTICK_RE).flatten.reject { |s| s.include?("\n") }.uniq
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# True when a FILES_EXCLUSION_RE match on this line governs the paths on
|
|
243
|
+
# it rather than merely annotating one (D19).
|
|
244
|
+
def governs_exclusion?(line)
|
|
245
|
+
match = line.match(FILES_EXCLUSION_RE)
|
|
246
|
+
return false unless match
|
|
247
|
+
|
|
248
|
+
first_backtick = line.index("`")
|
|
249
|
+
first_backtick.nil? || match.begin(0) < first_backtick
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def files_heading?(heading)
|
|
253
|
+
stripped = heading.to_s.sub(/\A#+\s*/, "")
|
|
254
|
+
first_token = stripped.split(/\s+/, 2).first.to_s.sub(/[^A-Za-z]+\z/, "")
|
|
255
|
+
return false unless first_token.casecmp?("Files")
|
|
256
|
+
|
|
257
|
+
!heading.to_s.match?(FILES_NEGATION_RE)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The S\d+ tokens of a file's headings, in heading order, taking a
|
|
261
|
+
# heading only when it owns at least one table data row (D5) - the same
|
|
262
|
+
# table-owning rule WorkGraphValidator.has_valid_matrix? and ReportScreen
|
|
263
|
+
# already apply, reused unmodified rather than adapted, so a prose
|
|
264
|
+
# mention of an S-label never manufactures a Proven-by source (334's
|
|
265
|
+
# post-execution review defect, in the other direction).
|
|
266
|
+
def proven_by_labels(text)
|
|
267
|
+
labels = []
|
|
268
|
+
NodeFile.split_by_headings(text).each do |heading, section|
|
|
269
|
+
next unless NodeFile.table_rows(section).any?
|
|
270
|
+
|
|
271
|
+
WorkGraphValidator.heading_tokens(heading).each do |token|
|
|
272
|
+
labels << token if token.match?(PROVEN_BY_LABEL_RE)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
labels
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# AtomicWrite (intent 334, n2, D19r): a sibling-temp-plus-rename write, the
|
|
5
|
+
# same shape as Lock#write (scripts/lib/lock.rb) - a content write is never
|
|
6
|
+
# an in-place truncate, so a crash mid-write can never leave an empty or
|
|
7
|
+
# half-written target on disk. The temp file is a SIBLING in the same
|
|
8
|
+
# directory as the target, never a system tmpdir, because File.rename can
|
|
9
|
+
# raise EXDEV when the temp and the target live on different filesystems.
|
|
10
|
+
# The temp name ends in .lock (post-execution review, non-blocking 5), the
|
|
11
|
+
# same property Lock#write_temp_path documents: an orphan left by a crash
|
|
12
|
+
# between the write and the rename is covered by the store's existing
|
|
13
|
+
# *.lock gitignore rule rather than swept into its git add -A auto-commit.
|
|
14
|
+
#
|
|
15
|
+
# The renamer is injectable so the interrupted-write case (a rename that
|
|
16
|
+
# raises) is testable with dependency injection, never eval or a global
|
|
17
|
+
# (D19r).
|
|
18
|
+
module AtomicWrite
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def write(path, content, renamer: File.method(:rename))
|
|
22
|
+
dir = File.dirname(path)
|
|
23
|
+
temp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{Time.now.to_f}.#{rand(0xFFFFFF)}.lock")
|
|
24
|
+
File.write(temp, content)
|
|
25
|
+
renamer.call(temp, path)
|
|
26
|
+
true
|
|
27
|
+
rescue StandardError
|
|
28
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# GraphEdges (intent 334, n1): the shared `needs` syntax and cycle-path check
|
|
5
|
+
# (327 D2r-D4r, D12r). Parses a "## Graph" section's edge lines into a node
|
|
6
|
+
# set and an edge map, the one parser an intent's graph.md and a roadmap's
|
|
7
|
+
# own ## Graph section both use unchanged (C1, fold A5): the same list-item
|
|
8
|
+
# grammar, the same root keyword, the same cycle walk. Deliberately loose
|
|
9
|
+
# about what an id looks like - a roadmap id is numeric ("334", "340a"), a
|
|
10
|
+
# node id carries a kind prefix ("n1") - the kind-prefix rule belongs to
|
|
11
|
+
# NodeFile and WorkGraphValidator, never here (D12r, fold A6).
|
|
12
|
+
#
|
|
13
|
+
# Grammar (D2r/D3r): a list item shaped "- <id> needs <target> [<target>
|
|
14
|
+
# ...]", ending at end of line. The single literal target "nothing" declares
|
|
15
|
+
# a root and must be the ONLY target on the line. Any line that does not
|
|
16
|
+
# start with "- <token> needs" is prose and is skipped in silence. A line
|
|
17
|
+
# that DOES look like an edge is held to the grammar strictly: no target at
|
|
18
|
+
# all, "nothing" alongside other targets, or a target token carrying
|
|
19
|
+
# sentence punctuation (a period, a colon, ...) rather than the loose
|
|
20
|
+
# id-token charset, is an error naming the line, never a silently
|
|
21
|
+
# mis-parsed edge or an invented node (fold A7).
|
|
22
|
+
#
|
|
23
|
+
# Pure and side-effect-free; never raises across the boundary, matching the
|
|
24
|
+
# Result-hash convention every library in scripts/lib/ follows.
|
|
25
|
+
module GraphEdges
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
EDGE_LEAD_RE = /\A-\s*(\S+)\s+needs\b(.*)\z/.freeze
|
|
29
|
+
TOKEN_RE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
|
|
30
|
+
ROOT_TARGET = "nothing"
|
|
31
|
+
|
|
32
|
+
# {nodes:, edges:, errors:} - nodes is the declared ids together with every
|
|
33
|
+
# id any edge targets (fold A5), in first-seen order; edges maps a declared
|
|
34
|
+
# id to its target ids (empty for a root); errors names each malformed
|
|
35
|
+
# edge-shaped line. Prose lines contribute nothing and raise nothing.
|
|
36
|
+
def parse(section_text)
|
|
37
|
+
declared_order = []
|
|
38
|
+
edges = {}
|
|
39
|
+
targeted = []
|
|
40
|
+
errors = []
|
|
41
|
+
|
|
42
|
+
section_text.to_s.each_line do |raw_line|
|
|
43
|
+
line = raw_line.chomp("\n").chomp("\r")
|
|
44
|
+
stripped = line.strip
|
|
45
|
+
next if stripped.empty?
|
|
46
|
+
|
|
47
|
+
m = stripped.match(EDGE_LEAD_RE)
|
|
48
|
+
next unless m
|
|
49
|
+
|
|
50
|
+
id = m[1]
|
|
51
|
+
raw_targets = m[2].to_s.strip.split(/\s+/)
|
|
52
|
+
|
|
53
|
+
if raw_targets.empty?
|
|
54
|
+
errors << "malformed edge line, no target: #{stripped.inspect}"
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if raw_targets.include?(ROOT_TARGET) && raw_targets.length > 1
|
|
59
|
+
errors << "root target #{ROOT_TARGET.inspect} mixed with a real target, root must be the only target: #{stripped.inspect}"
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
bad_token = raw_targets.find { |t| t != ROOT_TARGET && !t.match?(TOKEN_RE) }
|
|
64
|
+
if bad_token
|
|
65
|
+
errors << "prose tail after targets: #{stripped.inspect}"
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if edges.key?(id)
|
|
70
|
+
errors << "duplicate node declaration for #{id.inspect}: #{stripped.inspect}"
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
targets = raw_targets == [ROOT_TARGET] ? [] : raw_targets
|
|
75
|
+
declared_order << id
|
|
76
|
+
edges[id] = targets
|
|
77
|
+
targeted.concat(targets)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nodes = declared_order.dup
|
|
81
|
+
targeted.each { |t| nodes << t unless nodes.include?(t) }
|
|
82
|
+
|
|
83
|
+
{ nodes: nodes, edges: edges, errors: errors }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The cycle as a path with the entry id repeated (D4r), or nil when the
|
|
87
|
+
# graph is acyclic. A node reachable by two distinct paths (a diamond) is
|
|
88
|
+
# never mistaken for a cycle: once a node's whole subtree is walked clean
|
|
89
|
+
# it is marked visited and never re-examined.
|
|
90
|
+
def cycle(edges)
|
|
91
|
+
visiting = {}
|
|
92
|
+
visited = {}
|
|
93
|
+
path = []
|
|
94
|
+
|
|
95
|
+
edges.each_key do |node|
|
|
96
|
+
next if visited[node]
|
|
97
|
+
found = walk(node, edges, visiting, visited, path)
|
|
98
|
+
return found if found
|
|
99
|
+
end
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def walk(node, edges, visiting, visited, path)
|
|
104
|
+
if visiting[node]
|
|
105
|
+
idx = path.index(node)
|
|
106
|
+
return path[idx..] + [node]
|
|
107
|
+
end
|
|
108
|
+
return nil if visited[node]
|
|
109
|
+
|
|
110
|
+
visiting[node] = true
|
|
111
|
+
path.push(node)
|
|
112
|
+
(edges[node] || []).each do |target|
|
|
113
|
+
found = walk(target, edges, visiting, visited, path)
|
|
114
|
+
return found if found
|
|
115
|
+
end
|
|
116
|
+
path.pop
|
|
117
|
+
visiting.delete(node)
|
|
118
|
+
visited[node] = true
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_edges"
|
|
5
|
+
require_relative "atomic_write"
|
|
6
|
+
|
|
7
|
+
# GraphFile (intent 334, n2): the four graph.md sections (## Goal,
|
|
8
|
+
# ## Decisions, ## Graph, ## Status), the verify:-none directive, and the two
|
|
9
|
+
# writers - write_status and append_decision. Both writers refuse a cyclic
|
|
10
|
+
# graph and both go through AtomicWrite (fold D19r). Fence-aware everywhere a
|
|
11
|
+
# heading is located, so a fenced example carrying a fake "## " line never
|
|
12
|
+
# splits or ends a real section (fold A8's sibling concern, applied to
|
|
13
|
+
# section boundaries rather than edge lines).
|
|
14
|
+
module GraphFile
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
|
|
18
|
+
DIRECTIVE_RE = /\A-\s*verify:\s*none(?:\s+reason=(.*))?\z/i.freeze
|
|
19
|
+
|
|
20
|
+
# {ok:, goal:, decisions:, graph:, status:, verify:, errors:}. `graph` is
|
|
21
|
+
# GraphEdges.parse's own Result hash, over the Graph section text with any
|
|
22
|
+
# verify:-none directive line stripped first (fold A8). A missing ## Graph
|
|
23
|
+
# section, or one that declares no nodes, is an error naming which (fold
|
|
24
|
+
# A10).
|
|
25
|
+
def parse(path)
|
|
26
|
+
return failure(["graph file not found: #{path}"]) unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
content = File.read(path)
|
|
29
|
+
goal = section_body(content, "## Goal")
|
|
30
|
+
decisions = section_body(content, "## Decisions")
|
|
31
|
+
graph_section = section_body(content, "## Graph")
|
|
32
|
+
status = section_body(content, "## Status")
|
|
33
|
+
|
|
34
|
+
errors = []
|
|
35
|
+
if graph_section.nil?
|
|
36
|
+
errors << "missing ## Graph section"
|
|
37
|
+
return { ok: false, goal: goal, decisions: decisions, graph: nil, status: status, verify: nil, errors: errors }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
directive, directive_errors, remaining = extract_verify_directive(graph_section)
|
|
41
|
+
errors.concat(directive_errors)
|
|
42
|
+
|
|
43
|
+
parsed_graph = GraphEdges.parse(strip_fenced_blocks(remaining))
|
|
44
|
+
errors.concat(parsed_graph[:errors])
|
|
45
|
+
errors << "## Graph declares no nodes" if parsed_graph[:nodes].empty?
|
|
46
|
+
|
|
47
|
+
{ ok: errors.empty?, goal: goal, decisions: decisions, graph: parsed_graph, status: status, verify: directive, errors: errors }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failure(errors)
|
|
51
|
+
{ ok: false, goal: nil, decisions: nil, graph: nil, status: nil, verify: nil, errors: errors }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rows shaped {node:, state:, detail:} parsed back from the ## Status
|
|
55
|
+
# table (D8r's round-trip guarantee).
|
|
56
|
+
def status_rows(path)
|
|
57
|
+
parsed = parse(path)
|
|
58
|
+
rows_from_table(parsed[:status])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replace the whole ## Status section with rows, creating the section if
|
|
62
|
+
# absent. Refuses a cyclic graph (D8r); goes through AtomicWrite (D19r).
|
|
63
|
+
def write_status(path, rows, renamer: File.method(:rename))
|
|
64
|
+
guard = refuse_if_cyclic(path)
|
|
65
|
+
return guard if guard
|
|
66
|
+
|
|
67
|
+
content = File.read(path)
|
|
68
|
+
new_content = replace_or_append_section(content, "## Status", render_status_table(rows))
|
|
69
|
+
AtomicWrite.write(path, new_content, renamer: renamer)
|
|
70
|
+
{ ok: true, errors: [] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Append one answered decision as a list item to ## Decisions (C26),
|
|
74
|
+
# leaving every other section byte-identical. Refuses a cyclic graph.
|
|
75
|
+
def append_decision(path, text, renamer: File.method(:rename))
|
|
76
|
+
guard = refuse_if_cyclic(path)
|
|
77
|
+
return guard if guard
|
|
78
|
+
|
|
79
|
+
content = File.read(path)
|
|
80
|
+
bounds = section_bounds(content, "## Decisions")
|
|
81
|
+
return { ok: false, errors: ["missing ## Decisions section"] } unless bounds
|
|
82
|
+
|
|
83
|
+
start_idx, end_idx = bounds
|
|
84
|
+
heading_line_end = line_end(content, start_idx)
|
|
85
|
+
body = content[heading_line_end...end_idx]
|
|
86
|
+
trimmed = body.rstrip
|
|
87
|
+
trailing = body[trimmed.length..]
|
|
88
|
+
new_body = trimmed.empty? ? "- #{text}#{trailing}" : "#{trimmed}\n- #{text}#{trailing}"
|
|
89
|
+
new_content = content[0...heading_line_end] + new_body + content[end_idx..]
|
|
90
|
+
AtomicWrite.write(path, new_content, renamer: renamer)
|
|
91
|
+
{ ok: true, errors: [] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def refuse_if_cyclic(path)
|
|
95
|
+
parsed = parse(path)
|
|
96
|
+
return { ok: false, errors: parsed[:errors] } if parsed[:graph].nil?
|
|
97
|
+
|
|
98
|
+
cyc = GraphEdges.cycle(parsed[:graph][:edges])
|
|
99
|
+
return { ok: false, errors: ["cyclic graph, refusing to write: #{cyc.join(' > ')}"] } if cyc
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- verify: none directive --------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def extract_verify_directive(graph_section)
|
|
107
|
+
return [nil, [], graph_section] if graph_section.nil?
|
|
108
|
+
|
|
109
|
+
reason = nil
|
|
110
|
+
present = false
|
|
111
|
+
errors = []
|
|
112
|
+
remaining_lines = []
|
|
113
|
+
|
|
114
|
+
graph_section.each_line do |line|
|
|
115
|
+
stripped = line.strip
|
|
116
|
+
m = stripped.match(DIRECTIVE_RE)
|
|
117
|
+
if m
|
|
118
|
+
present = true
|
|
119
|
+
captured = m[1].to_s.strip
|
|
120
|
+
if captured.empty?
|
|
121
|
+
errors << "verify: none requires reason=<text>: #{stripped.inspect}"
|
|
122
|
+
else
|
|
123
|
+
reason = captured
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
remaining_lines << line
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
directive = present ? { reason: reason } : nil
|
|
131
|
+
[directive, errors, remaining_lines.join]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# D18: drop every fenced line from `text`, so a fenced example edge inside a
|
|
135
|
+
# hand-written graph.md or roadmap ## Graph section is never handed to
|
|
136
|
+
# GraphEdges as real edge text. Non-fenced lines pass through unchanged.
|
|
137
|
+
def strip_fenced_blocks(text)
|
|
138
|
+
lines = []
|
|
139
|
+
each_fence_line(text) { |line, fenced| lines << line unless fenced }
|
|
140
|
+
lines.join
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- fence-aware section location ---------------------------------------------
|
|
144
|
+
|
|
145
|
+
def each_fence_line(text)
|
|
146
|
+
return enum_for(:each_fence_line, text) unless block_given?
|
|
147
|
+
|
|
148
|
+
marker = nil
|
|
149
|
+
text.to_s.each_line do |line|
|
|
150
|
+
if marker
|
|
151
|
+
yield line, true
|
|
152
|
+
m = line.match(FENCE_LINE_RE)
|
|
153
|
+
next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
|
|
154
|
+
next unless line.sub(FENCE_LINE_RE, "").strip.empty?
|
|
155
|
+
|
|
156
|
+
marker = nil
|
|
157
|
+
else
|
|
158
|
+
m = line.match(FENCE_LINE_RE)
|
|
159
|
+
if m
|
|
160
|
+
marker = [m[1][0], m[1].length]
|
|
161
|
+
yield line, true
|
|
162
|
+
else
|
|
163
|
+
yield line, false
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# [start_of_heading_line, start_of_next_top_level_heading_or_EOF] byte
|
|
170
|
+
# offsets for the FIRST line, outside any fence, whose stripped text
|
|
171
|
+
# exactly equals heading_text. nil when no such line exists.
|
|
172
|
+
def section_bounds(content, heading_text)
|
|
173
|
+
offset = 0
|
|
174
|
+
heading_start = nil
|
|
175
|
+
|
|
176
|
+
each_fence_line(content) do |line, fenced|
|
|
177
|
+
if !fenced && heading_start.nil? && line.strip == heading_text
|
|
178
|
+
heading_start = offset
|
|
179
|
+
elsif !fenced && heading_start && offset > heading_start && line.match?(/\A##[^#]/)
|
|
180
|
+
return [heading_start, offset]
|
|
181
|
+
end
|
|
182
|
+
offset += line.length
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
heading_start ? [heading_start, offset] : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def section_body(content, heading_text)
|
|
189
|
+
bounds = section_bounds(content, heading_text)
|
|
190
|
+
return nil unless bounds
|
|
191
|
+
|
|
192
|
+
start_idx, end_idx = bounds
|
|
193
|
+
content[(line_end(content, start_idx))...end_idx].to_s
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def line_end(content, start_idx)
|
|
197
|
+
idx = content.index("\n", start_idx)
|
|
198
|
+
idx ? idx + 1 : content.length
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def replace_or_append_section(content, heading_text, body_text)
|
|
202
|
+
rendered_body = body_text.to_s
|
|
203
|
+
rendered_body += "\n" unless rendered_body.end_with?("\n")
|
|
204
|
+
section = "#{heading_text}\n#{rendered_body}"
|
|
205
|
+
|
|
206
|
+
bounds = section_bounds(content, heading_text)
|
|
207
|
+
if bounds
|
|
208
|
+
start_idx, end_idx = bounds
|
|
209
|
+
tail = content[end_idx..].to_s
|
|
210
|
+
section += "\n" unless tail.empty?
|
|
211
|
+
content[0...start_idx] + section + tail
|
|
212
|
+
else
|
|
213
|
+
head = content.dup
|
|
214
|
+
head += "\n" unless head.end_with?("\n")
|
|
215
|
+
head += "\n" unless head.end_with?("\n\n")
|
|
216
|
+
head + section
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# --- table rendering and reading -----------------------------------------------
|
|
221
|
+
|
|
222
|
+
def render_status_table(rows)
|
|
223
|
+
lines = ["| Node | State | Detail |", "| --- | --- | --- |"]
|
|
224
|
+
rows.each { |r| lines << "| #{r[:node]} | #{r[:state]} | #{r[:detail]} |" }
|
|
225
|
+
"#{lines.join("\n")}\n"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def rows_from_table(text)
|
|
229
|
+
return [] if text.nil?
|
|
230
|
+
|
|
231
|
+
lines = []
|
|
232
|
+
each_fence_line(text) do |line, fenced|
|
|
233
|
+
next if fenced
|
|
234
|
+
|
|
235
|
+
stripped = line.strip
|
|
236
|
+
lines << stripped if stripped.start_with?("|")
|
|
237
|
+
end
|
|
238
|
+
sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
|
|
239
|
+
return [] unless sep_idx
|
|
240
|
+
|
|
241
|
+
lines[(sep_idx + 1)..].map do |l|
|
|
242
|
+
cells = l.split("|", -1).map(&:strip)[1..-2].to_a
|
|
243
|
+
{ node: cells[0], state: cells[1], detail: cells[2] }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|