bacon-tracker 1.0.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +430 -0
- data/bin/tracker-dashboard +47 -0
- data/bin/tracker-init +267 -0
- data/lib/bacon_tracker/assets/app.js +1258 -0
- data/lib/bacon_tracker/assets/decisions.js +382 -0
- data/lib/bacon_tracker/assets/docs.js +456 -0
- data/lib/bacon_tracker/assets/logic.js +85 -0
- data/lib/bacon_tracker/assets/theme.js +33 -0
- data/lib/bacon_tracker/commands/tracker.md +269 -0
- data/lib/bacon_tracker/dashboard.rb +168 -0
- data/lib/bacon_tracker/launcher.rb +64 -0
- data/lib/bacon_tracker/server.rb +476 -0
- data/lib/bacon_tracker/tasks.rb +465 -0
- data/lib/bacon_tracker/version.rb +3 -0
- data/lib/bacon_tracker/views/_board_css.erb +122 -0
- data/lib/bacon_tracker/views/_detail_css.erb +109 -0
- data/lib/bacon_tracker/views/_header_css.erb +82 -0
- data/lib/bacon_tracker/views/_markdown_css.erb +81 -0
- data/lib/bacon_tracker/views/_root_css.erb +41 -0
- data/lib/bacon_tracker/views/_theme_boot.erb +1 -0
- data/lib/bacon_tracker/views/dashboard.erb +281 -0
- data/lib/bacon_tracker/views/decisions.erb +211 -0
- data/lib/bacon_tracker/views/docs.erb +234 -0
- data/lib/bacon_tracker/views/index.erb +1065 -0
- data/lib/bacon_tracker.rb +1993 -0
- metadata +213 -0
|
@@ -0,0 +1,1993 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'date'
|
|
6
|
+
require 'kramdown'
|
|
7
|
+
require 'kramdown-parser-gfm'
|
|
8
|
+
|
|
9
|
+
module BaconTracker
|
|
10
|
+
STORY_DIRS = {
|
|
11
|
+
'features' => { type: 'feature', ext: '.feature' },
|
|
12
|
+
'bugs' => { type: 'bug', ext: '.md' },
|
|
13
|
+
'chores' => { type: 'chore', ext: '.md' }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
STAGES = %w[1_icebox 2_backlog 3_started 4_done].freeze
|
|
17
|
+
|
|
18
|
+
# Decision statuses (BT-ADR-0014). Unlike stages these are not a linear flow:
|
|
19
|
+
# `proposed` and `accepted` are non-terminal, the other three are outcomes.
|
|
20
|
+
# The directory named for a status is the source of truth for it.
|
|
21
|
+
STATUSES = %w[proposed accepted rejected deprecated superseded].freeze
|
|
22
|
+
|
|
23
|
+
STATUS_MAP = {
|
|
24
|
+
'1_icebox' => 'icebox',
|
|
25
|
+
'2_backlog' => 'backlog',
|
|
26
|
+
'3_started' => 'started',
|
|
27
|
+
'4_done' => 'done'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Bundled Claude slash commands that tracker-init installs for the user.
|
|
31
|
+
COMMANDS_DIR = File.expand_path('bacon_tracker/commands', __dir__).freeze
|
|
32
|
+
|
|
33
|
+
# Default story templates - single source for tracker-init's _template files
|
|
34
|
+
# and Core#create_story's no-template fallback.
|
|
35
|
+
module Templates
|
|
36
|
+
FEATURE = <<~GHERKIN.freeze
|
|
37
|
+
Feature: Name the Feature
|
|
38
|
+
|
|
39
|
+
Scenario: Happy path
|
|
40
|
+
Given a starting condition
|
|
41
|
+
When an action is performed
|
|
42
|
+
Then the expected outcome occurs
|
|
43
|
+
|
|
44
|
+
# Subtasks (optional): add "- [ ] a step" lines below to track progress -
|
|
45
|
+
# the board counts them and lets you tick them off.
|
|
46
|
+
GHERKIN
|
|
47
|
+
|
|
48
|
+
def self.markdown(type_name)
|
|
49
|
+
<<~MD
|
|
50
|
+
Title: #{type_name.capitalize} Title
|
|
51
|
+
|
|
52
|
+
## Description
|
|
53
|
+
|
|
54
|
+
<!-- Describe the #{type_name.downcase} here -->
|
|
55
|
+
|
|
56
|
+
## Acceptance Criteria
|
|
57
|
+
|
|
58
|
+
<!-- Add "- [ ] a step" lines here - the board counts them and lets you
|
|
59
|
+
tick them off. (No live checkbox ships in the template, so a new
|
|
60
|
+
story starts with zero subtasks rather than a phantom 0/1 - BT-108.) -->
|
|
61
|
+
MD
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The decision record template (BT-ADR-0014) - single source for
|
|
65
|
+
# tracker-init's scaffolding, so a new project starts contract-conforming.
|
|
66
|
+
DECISION = <<~MD.freeze
|
|
67
|
+
---
|
|
68
|
+
status: proposed
|
|
69
|
+
date: 2026-01-01
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
# Name the decision
|
|
73
|
+
|
|
74
|
+
- Date: 2026-01-01
|
|
75
|
+
- Related: -
|
|
76
|
+
|
|
77
|
+
## Status
|
|
78
|
+
|
|
79
|
+
Proposed
|
|
80
|
+
|
|
81
|
+
## Context
|
|
82
|
+
|
|
83
|
+
What is the issue that we're seeing that is motivating this decision?
|
|
84
|
+
|
|
85
|
+
## Decision
|
|
86
|
+
|
|
87
|
+
What is the change that we're proposing and/or doing?
|
|
88
|
+
|
|
89
|
+
## Consequences
|
|
90
|
+
|
|
91
|
+
What becomes easier or more difficult because of this change? Record the
|
|
92
|
+
costs as plainly as the benefits.
|
|
93
|
+
|
|
94
|
+
<!--
|
|
95
|
+
Filename: <NS>-ADR-NNNN-slug.md, in the directory named for its status.
|
|
96
|
+
Take NNNN from .next-id. `status` must match that directory; `date` is the
|
|
97
|
+
day the decision took effect, ISO-8601 - replace the placeholder.
|
|
98
|
+
|
|
99
|
+
`status` and `date` are the only required keys. Add deciders, supersedes,
|
|
100
|
+
superseded_by, stories, canonical or tags only when they have a value.
|
|
101
|
+
|
|
102
|
+
An accepted record is immutable - append "## Amendment - YYYY-MM-DD (STORY)",
|
|
103
|
+
or supersede it with a new one.
|
|
104
|
+
-->
|
|
105
|
+
MD
|
|
106
|
+
|
|
107
|
+
def self.for(ext, type_name)
|
|
108
|
+
ext == '.feature' ? FEATURE : markdown(type_name)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class Configuration
|
|
113
|
+
# tracker_root holds the stories; docs_root holds the prose and decisions_root
|
|
114
|
+
# the ADRs beneath it. They are
|
|
115
|
+
# separate because a project's stories and its decisions need not share a
|
|
116
|
+
# checkout - a product-wide tracker can sit in a parent repo while each
|
|
117
|
+
# repo keeps its own decisions (BT-ADR-0016, BT-167). decisions_root is nil
|
|
118
|
+
# until set.
|
|
119
|
+
# project_root anchors the identity files (README, CHANGELOG, VERSION);
|
|
120
|
+
# version_path overrides the VERSION search when a project keeps it
|
|
121
|
+
# somewhere the fixed order cannot see (BT-149, BT-162).
|
|
122
|
+
attr_accessor :namespace, :version_path
|
|
123
|
+
attr_reader :tracker_root, :docs_root, :decisions_root, :project_root
|
|
124
|
+
|
|
125
|
+
def initialize
|
|
126
|
+
@namespace = 'BCN'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Roots are expanded on assignment: a Rakefile's `~/proj/tracker` would
|
|
130
|
+
# otherwise be a literal directory named `~` under the CWD, and every
|
|
131
|
+
# Dir.exist? check would report the real tracker as absent (BT-179).
|
|
132
|
+
%i[tracker_root docs_root decisions_root project_root].each do |root|
|
|
133
|
+
define_method("#{root}=") { |v| instance_variable_set("@#{root}", v && File.expand_path(v)) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def next_id_path
|
|
137
|
+
File.join(tracker_root, '.next-id')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def backlog_path
|
|
141
|
+
File.join(tracker_root, 'backlog.md')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Every root this project is allowed to touch. Nils are dropped rather than
|
|
145
|
+
# expanded - an unset root must never widen a scope check into a wildcard
|
|
146
|
+
# (BT-136).
|
|
147
|
+
def roots
|
|
148
|
+
[tracker_root, docs_root, decisions_root].compact.reject(&:empty?).map { |r| File.expand_path(r) }.uniq
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def decisions_next_id_path
|
|
152
|
+
File.join(decisions_root, '.next-id')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The ordered list of records in decisions/proposed/ - the decision todo
|
|
156
|
+
# list, named for its membership rule rather than borrowing "backlog",
|
|
157
|
+
# which vocabulary.md reserves for a story stage (BT-ADR-0014).
|
|
158
|
+
def proposed_path
|
|
159
|
+
File.join(decisions_root, 'proposed.md')
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.configure
|
|
164
|
+
@config ||= Configuration.new
|
|
165
|
+
yield @config if block_given?
|
|
166
|
+
@config
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.config
|
|
170
|
+
@config || configure
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Filesystem-safe slug from a human title - the single source shared by
|
|
174
|
+
# Core (story filenames) and Dashboard (project slugs) so the two can never
|
|
175
|
+
# drift apart.
|
|
176
|
+
def self.slugify(title)
|
|
177
|
+
title.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class Core
|
|
181
|
+
attr_reader :config
|
|
182
|
+
|
|
183
|
+
# A markdown checklist line: "- [ ] task" / "* [x] task". Groups: prefix,
|
|
184
|
+
# state, suffix - so a toggle can swap the state without touching the rest.
|
|
185
|
+
# The client never re-derives which lines are subtasks: parse_story_file
|
|
186
|
+
# emits subtask_lines (body-line addresses) and the board renders/toggles
|
|
187
|
+
# by those, so this regex plus subtask_line_indices is the single source.
|
|
188
|
+
SUBTASK = /\A(\s*[-*] \[)([ xX])(\] )/
|
|
189
|
+
|
|
190
|
+
def initialize(config)
|
|
191
|
+
@config = config
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Serializes read-modify-write mutations (story files, backlog.md) across
|
|
195
|
+
# threads and processes. flock is not re-entrant, so nested Core calls
|
|
196
|
+
# (toggle_subtask → update_story) skip re-acquisition via a thread-local.
|
|
197
|
+
def with_lock(&block) = with_lock_on(@config.tracker_root, &block)
|
|
198
|
+
|
|
199
|
+
# Decisions carry their own lock file in their own root - the two record
|
|
200
|
+
# kinds may live in different repositories (BT-ADR-0016), so one mutex
|
|
201
|
+
# cannot serve both.
|
|
202
|
+
def with_decisions_lock(&block) = with_lock_on(@config.decisions_root, &block)
|
|
203
|
+
|
|
204
|
+
def with_lock_on(root)
|
|
205
|
+
held = (Thread.current[:bacon_tracker_locks] ||= {})
|
|
206
|
+
return yield if held[root]
|
|
207
|
+
|
|
208
|
+
FileUtils.mkdir_p(root)
|
|
209
|
+
File.open(File.join(root, '.lock'), File::RDWR | File::CREAT) do |f|
|
|
210
|
+
f.flock(File::LOCK_EX)
|
|
211
|
+
held[root] = true
|
|
212
|
+
begin
|
|
213
|
+
yield
|
|
214
|
+
ensure
|
|
215
|
+
held.delete(root)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# All story reads go through here - CRLF files are normalized to LF so
|
|
221
|
+
# the \n-anchored frontmatter handling works everywhere.
|
|
222
|
+
def read_story(path)
|
|
223
|
+
# `scrub` replaces invalid byte sequences so a non-UTF-8 / corrupted file
|
|
224
|
+
# can't raise out of parse_story_file and 500 every caller of all_stories
|
|
225
|
+
# (BT-096). Strip a leading UTF-8 BOM so it can't defeat the
|
|
226
|
+
# `start_with?("---\n")` / `# key:` frontmatter checks (BT-095), then
|
|
227
|
+
# normalize CRLF to LF.
|
|
228
|
+
File.read(path, encoding: 'utf-8').scrub.delete_prefix("\uFEFF").gsub("\r\n", "\n")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Glob metacharacters that must be backslash-escaped when a literal string
|
|
232
|
+
# (a tracker_root, a namespace, an id) is spliced into a Dir.glob pattern.
|
|
233
|
+
GLOB_META = /[*?\[\]{}\\]/
|
|
234
|
+
|
|
235
|
+
# A '.feature' comment-frontmatter header line ('# key: value'). The single
|
|
236
|
+
# source used by every place that delimits the leading header block from the
|
|
237
|
+
# Gherkin body (parse_story_file, set_field_in, locked_update_story).
|
|
238
|
+
FEATURE_HEADER = /^# \w+:/
|
|
239
|
+
|
|
240
|
+
# Escape glob metacharacters in a literal so it matches itself verbatim.
|
|
241
|
+
def escape_glob(str)
|
|
242
|
+
str.gsub(GLOB_META) { |c| "\\#{c}" }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Dir.glob with the base directory escaped - a tracker_root containing
|
|
246
|
+
# glob metacharacters ([, ], {, }, *, ?) must not change what matches.
|
|
247
|
+
def safe_glob(dir, pattern)
|
|
248
|
+
Dir.glob(File.join(escape_glob(dir), pattern))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def next_id_value
|
|
252
|
+
path = @config.next_id_path
|
|
253
|
+
id = File.exist?(path) ? File.read(path, encoding: 'utf-8').strip.to_i : 0
|
|
254
|
+
id.zero? ? 1 : id
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def consume_id
|
|
258
|
+
consume_counter(@config.next_id_path) { max_story_id }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Issue the next id from a locked counter file. The floor is what makes this
|
|
262
|
+
# safe rather than the lock alone: a corrupted or stale counter (merge
|
|
263
|
+
# conflict, hand edit, stale checkout) must never reissue a live id, so it is
|
|
264
|
+
# raised above the highest id already on disk on every read.
|
|
265
|
+
#
|
|
266
|
+
# The floor is record-shaped and therefore a block: stories count story
|
|
267
|
+
# files, decisions count decision files, and neither may floor against the
|
|
268
|
+
# other's corpus (BT-154).
|
|
269
|
+
def consume_counter(path)
|
|
270
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
271
|
+
File.open(path, File::RDWR | File::CREAT) do |f|
|
|
272
|
+
f.flock(File::LOCK_EX)
|
|
273
|
+
id = f.read.strip.to_i
|
|
274
|
+
floor = yield.to_i + 1
|
|
275
|
+
id = floor if id < floor
|
|
276
|
+
f.rewind
|
|
277
|
+
f.write((id + 1).to_s)
|
|
278
|
+
f.truncate(f.pos)
|
|
279
|
+
id
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def format_id(n)
|
|
284
|
+
format("#{@config.namespace.gsub('%', '%%')}-%03d", n)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Decision ids are a separate sequence in the same namespace, four digits
|
|
288
|
+
# wide, carrying the ADR token that keeps them distinct from story ids
|
|
289
|
+
# (BT-ADR-0014).
|
|
290
|
+
def consume_decision_id
|
|
291
|
+
consume_counter(@config.decisions_next_id_path) { max_decision_number }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def format_decision_id(n)
|
|
295
|
+
format("#{@config.namespace.gsub('%', '%%')}-ADR-%04d", n)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def max_decision_number
|
|
299
|
+
decisions.map { |r| r[:number] }.max.to_i
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The story-ID pattern for this namespace - the single source for every
|
|
303
|
+
# ID match. Unanchored: use filename_id_pattern for filename starts.
|
|
304
|
+
def id_pattern
|
|
305
|
+
@id_pattern ||= /#{Regexp.escape(@config.namespace)}-\d+/
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def filename_id_pattern
|
|
309
|
+
@filename_id_pattern ||= /\A#{id_pattern}/
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def slugify(title)
|
|
313
|
+
BaconTracker.slugify(title)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# A non-empty slug for a filename. slugify keeps only [a-z0-9], so a title
|
|
317
|
+
# with no ASCII alphanumerics (all-CJK, all-punctuation) slugs to "" - fall
|
|
318
|
+
# back to 'untitled' so the file is "<id>-untitled.ext", not a dangling
|
|
319
|
+
# "<id>-.ext" with a blank humanized name (BT-095). The real title stays in
|
|
320
|
+
# the file's Title:/Feature: line.
|
|
321
|
+
def filename_slug(title)
|
|
322
|
+
slug = slugify(title)
|
|
323
|
+
slug.empty? ? 'untitled' : slug
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def story_dirs
|
|
327
|
+
STORY_DIRS.flat_map do |dir, meta|
|
|
328
|
+
STAGES.map do |stage|
|
|
329
|
+
path = File.join(@config.tracker_root, dir, stage)
|
|
330
|
+
{ path: path, type: meta[:type], ext: meta[:ext], stage: stage, dir: dir }
|
|
331
|
+
end
|
|
332
|
+
end.select { |d| Dir.exist?(d[:path]) }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def find_story(id)
|
|
336
|
+
# Only a well-formed story id may address a file - an id containing '/'
|
|
337
|
+
# or '..' would otherwise let find_story (and its mutating callers:
|
|
338
|
+
# delete/set_stage/update/toggle) reach outside the tracker via the glob
|
|
339
|
+
# (BT-097). id_pattern is namespace-anchored NS-<digits>.
|
|
340
|
+
return nil unless id.to_s.match?(/\A#{id_pattern}\z/)
|
|
341
|
+
|
|
342
|
+
glob_safe_id = escape_glob(id)
|
|
343
|
+
story_dirs.each do |d|
|
|
344
|
+
matches = safe_glob(d[:path], "#{glob_safe_id}-*")
|
|
345
|
+
return d.merge(file: matches.first) if matches.any?
|
|
346
|
+
end
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# One `git log` pass mapping every tracked file (relative to tracker_root) to
|
|
351
|
+
# its first-commit timestamp - story:migrate used to spawn git per file.
|
|
352
|
+
# `--relative` makes the paths tracker_root-relative (git otherwise prints
|
|
353
|
+
# repo-root-relative paths, which never matched when tracker_root is a subdir
|
|
354
|
+
# of the repo - the tracker-init layout - silently falling back to mtime
|
|
355
|
+
# ordering, BT-105).
|
|
356
|
+
def git_first_commit_times
|
|
357
|
+
out = `git -C #{Shellwords.shellescape(@config.tracker_root)} log --diff-filter=A --format=%x00%at --name-only --relative 2>/dev/null`
|
|
358
|
+
map = {}
|
|
359
|
+
ts = nil
|
|
360
|
+
out.each_line(chomp: true) do |line|
|
|
361
|
+
if line.start_with?("\u0000")
|
|
362
|
+
ts = line.delete_prefix("\u0000").to_i
|
|
363
|
+
elsif !line.empty? && ts
|
|
364
|
+
map[line] = ts # log is newest-first, so the oldest add wins by overwriting
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
map
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def already_migrated?(file)
|
|
371
|
+
File.basename(file).match?(filename_id_pattern)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def frontmatter_md(id, type, status)
|
|
375
|
+
"---\nid: #{id}\ntype: #{type}\nstatus: #{status}\n---\n\n"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def frontmatter_feature(id, type, status)
|
|
379
|
+
"# id: #{id}\n# type: #{type}\n# status: #{status}\n\n"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def backlog_ids
|
|
383
|
+
return [] unless File.exist?(@config.backlog_path)
|
|
384
|
+
|
|
385
|
+
File.readlines(@config.backlog_path, encoding: 'utf-8').filter_map { |line| backlog_line_id(line) }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Entry lines only, by the same rule as backlog_ids - a heading that merely
|
|
389
|
+
# mentions an id ("# Backlog (see NS-099)") is not the next task (BT-179).
|
|
390
|
+
def backlog_lines
|
|
391
|
+
return [] unless File.exist?(@config.backlog_path)
|
|
392
|
+
|
|
393
|
+
File.readlines(@config.backlog_path, encoding: 'utf-8').select { |l| backlog_line_id(l) }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def backlog_story_files
|
|
397
|
+
STORY_DIRS.keys.flat_map do |dir|
|
|
398
|
+
safe_glob(File.join(@config.tracker_root, dir, '2_backlog'), "#{escape_glob(@config.namespace)}-*.{md,feature}")
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def humanize_slug(filename)
|
|
403
|
+
base = File.basename(filename, File.extname(filename))
|
|
404
|
+
base.sub(/\A#{id_pattern}-/, '').gsub('-', ' ')
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Delegates to set_frontmatter_field: the substitution stays scoped to the
|
|
408
|
+
# frontmatter block (a body line like "status: healthy" is never touched)
|
|
409
|
+
# and a missing status field is added instead of silently skipped.
|
|
410
|
+
def update_frontmatter_status(path, new_status)
|
|
411
|
+
set_frontmatter_field(path, 'status', new_status)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# A title or frontmatter value must stay on its own line - a newline would
|
|
415
|
+
# inject extra frontmatter keys or backlog.md entries.
|
|
416
|
+
def assert_safe_title!(title)
|
|
417
|
+
raise ArgumentError, 'title must be a string without newlines' if !title.is_a?(String) || title.include?("\n")
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def set_frontmatter_field(path, field, value)
|
|
421
|
+
atomic_write(path, set_field_in(read_story(path), File.extname(path), field, value, File.basename(path)))
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Pure content transform behind set_frontmatter_field - update_story folds
|
|
425
|
+
# several field edits into one in-memory content and writes once.
|
|
426
|
+
def set_field_in(content, ext, field, value, label)
|
|
427
|
+
raise ArgumentError, "#{field} value must not contain newlines" if value.to_s.include?("\n")
|
|
428
|
+
|
|
429
|
+
# An empty value means "remove the field", not "set it to blank" - without
|
|
430
|
+
# this, "" is truthy and would write a dangling "field: " line (BT-095).
|
|
431
|
+
value = nil if value == ''
|
|
432
|
+
|
|
433
|
+
# A leading '---' block is YAML frontmatter regardless of extension, so it
|
|
434
|
+
# must be edited as YAML - even for a '.feature' file authored that way,
|
|
435
|
+
# matching parse_story_file's precedence (BT-120).
|
|
436
|
+
if content.start_with?("---\n")
|
|
437
|
+
pattern = /^#{Regexp.escape(field)}: .*\n/
|
|
438
|
+
parts = content.split(/^---\n/, 3)
|
|
439
|
+
raise ArgumentError, "malformed frontmatter in #{label}" unless parts.size == 3
|
|
440
|
+
|
|
441
|
+
if value
|
|
442
|
+
enc = yaml_scalar(value)
|
|
443
|
+
new_fm = parts[1].match?(pattern) ? parts[1].sub(pattern) { "#{field}: #{enc}\n" } \
|
|
444
|
+
: "#{parts[1]}#{field}: #{enc}\n"
|
|
445
|
+
"---\n#{new_fm}---\n#{parts[2]}"
|
|
446
|
+
else
|
|
447
|
+
"---\n#{parts[1].gsub(pattern, '')}---\n#{parts[2]}"
|
|
448
|
+
end
|
|
449
|
+
elsif ext == '.feature'
|
|
450
|
+
pattern = /^# #{Regexp.escape(field)}: .*\n/
|
|
451
|
+
# Scope the edit to the leading '# key: value' header block only - a
|
|
452
|
+
# body comment further down (e.g. '# size: TBD' in a scenario) must not
|
|
453
|
+
# be matched or rewritten (BT-099). header_end is the first non-header line.
|
|
454
|
+
lines = content.lines
|
|
455
|
+
header_end = lines.index { |l| !l.match?(FEATURE_HEADER) } || lines.size
|
|
456
|
+
header = lines[0...header_end]
|
|
457
|
+
rest = lines[header_end..] || []
|
|
458
|
+
header =
|
|
459
|
+
if value
|
|
460
|
+
new_line = "# #{field}: #{value}\n"
|
|
461
|
+
if header.any? { |l| l.match?(pattern) }
|
|
462
|
+
header.map { |l| l.match?(pattern) ? new_line : l }
|
|
463
|
+
else
|
|
464
|
+
header + [new_line]
|
|
465
|
+
end
|
|
466
|
+
else
|
|
467
|
+
header.reject { |l| l.match?(pattern) }
|
|
468
|
+
end
|
|
469
|
+
(header + rest).join
|
|
470
|
+
else
|
|
471
|
+
raise ArgumentError, "#{label} has no recognizable frontmatter"
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Encode a value as a single-line YAML scalar so field values containing
|
|
476
|
+
# ':' , '#', or other YAML indicators round-trip instead of producing an
|
|
477
|
+
# unparseable frontmatter block - which Psych would reject on the next read,
|
|
478
|
+
# silently dropping every field of the story (BT-098). Only for YAML
|
|
479
|
+
# frontmatter ('.md', or a '.feature' authored with '---'); the '.feature'
|
|
480
|
+
# gherkin-comment format is parsed by our own 'split(": ", 2)' and must stay
|
|
481
|
+
# unquoted (its branch never calls this). Newlines are already rejected
|
|
482
|
+
# upstream; line_width: -1 stops Psych folding a long plain scalar (a dozen
|
|
483
|
+
# blocked_by ids) onto an indented continuation line that the next
|
|
484
|
+
# `^field: .*\n` edit would orphan (BT-179).
|
|
485
|
+
def yaml_scalar(value)
|
|
486
|
+
value.to_s.to_yaml(line_width: -1).sub(/\A---\s*/, '').chomp
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Write via a sibling temp file + rename so a concurrent lock-free reader
|
|
490
|
+
# (all_stories/backlog_lines run outside the write lock) always sees a
|
|
491
|
+
# complete file, never the truncated middle of an in-place File.write, and
|
|
492
|
+
# a failed write leaves the original untouched (BT-079). rename(2) is atomic
|
|
493
|
+
# within a filesystem, and the temp lives in the same dir to stay on it.
|
|
494
|
+
def atomic_write(path, content)
|
|
495
|
+
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.tmp.#{Process.pid}")
|
|
496
|
+
File.write(tmp, content)
|
|
497
|
+
File.rename(tmp, path)
|
|
498
|
+
rescue StandardError
|
|
499
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
500
|
+
raise
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Normalize a T-shirt size to S/M/L (upcased), treating nil/"" as "unset"
|
|
504
|
+
# and raising on anything else - the one rule shared by create_story and
|
|
505
|
+
# update_story so the two entry points can't disagree (BT-101).
|
|
506
|
+
def normalized_size!(size)
|
|
507
|
+
return nil if size.nil?
|
|
508
|
+
raise ArgumentError, 'size must be a string' unless size.is_a?(String)
|
|
509
|
+
return nil if size.empty?
|
|
510
|
+
|
|
511
|
+
up = size.upcase
|
|
512
|
+
raise ArgumentError, 'size must be S, M, or L' unless %w[S M L].include?(up)
|
|
513
|
+
|
|
514
|
+
up
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def move_to_stage(result, new_stage)
|
|
518
|
+
old_path = result[:file]
|
|
519
|
+
new_dir = File.join(@config.tracker_root, result[:dir], new_stage)
|
|
520
|
+
FileUtils.mkdir_p(new_dir)
|
|
521
|
+
new_path = File.join(new_dir, File.basename(old_path))
|
|
522
|
+
if File.exist?(new_path)
|
|
523
|
+
raise ArgumentError,
|
|
524
|
+
"#{File.basename(old_path)} already exists in #{new_stage} - refusing to overwrite (duplicate ID? run story:lint)."
|
|
525
|
+
end
|
|
526
|
+
FileUtils.mv(old_path, new_path)
|
|
527
|
+
new_path
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def backlog_add(id, filename)
|
|
531
|
+
title = humanize_slug(filename)
|
|
532
|
+
existing = File.exist?(@config.backlog_path) ? File.read(@config.backlog_path, encoding: 'utf-8') : ''
|
|
533
|
+
# Normalize the trailing newline first so a hand-edited backlog.md that
|
|
534
|
+
# doesn't end in one doesn't glue the new entry onto the last line
|
|
535
|
+
# (BT-103). Read + atomic_write also spares a lock-free reader a torn append.
|
|
536
|
+
existing += "\n" unless existing.empty? || existing.end_with?("\n")
|
|
537
|
+
atomic_write(@config.backlog_path, "#{existing}- #{id} #{title}\n")
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def backlog_remove(id)
|
|
541
|
+
return unless File.exist?(@config.backlog_path)
|
|
542
|
+
|
|
543
|
+
lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
|
|
544
|
+
atomic_write(@config.backlog_path, lines.reject { |l| backlog_line_id(l) == id }.join)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# backlog.md membership derives from 2_backlog/ - drop story lines whose
|
|
548
|
+
# file is gone (phantom) and append lines for stories lacking one
|
|
549
|
+
# (unlisted, at the bottom). Runs inside mutation paths, under the lock,
|
|
550
|
+
# so external edits converge instead of drifting until someone lints.
|
|
551
|
+
def heal_backlog!
|
|
552
|
+
on_disk = backlog_story_files.to_h { |f| [File.basename(f)[filename_id_pattern], f] }
|
|
553
|
+
on_disk.delete(nil)
|
|
554
|
+
lines = File.exist?(@config.backlog_path) ? File.readlines(@config.backlog_path, encoding: 'utf-8') : []
|
|
555
|
+
kept = lines.select { |l| (id = backlog_line_id(l)).nil? || on_disk.key?(id) }
|
|
556
|
+
listed = kept.filter_map { |l| backlog_line_id(l) }
|
|
557
|
+
additions = (on_disk.keys - listed).map { |id| "- #{id} #{humanize_slug(on_disk[id])}\n" }
|
|
558
|
+
atomic_write(@config.backlog_path, (kept + additions).join) if kept != lines || additions.any?
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# proposed.md's counterpart of heal_backlog!: membership is derived from
|
|
562
|
+
# decisions/proposed/, the order is the human's, and non-entry lines (the
|
|
563
|
+
# heading, blanks, comments) are preserved. Same contract, different record
|
|
564
|
+
# shape - the two share the rule, not the code path, because the id pattern
|
|
565
|
+
# and the title source differ (BT-154).
|
|
566
|
+
def heal_proposed!
|
|
567
|
+
path = @config.proposed_path
|
|
568
|
+
return unless @config.decisions_root && Dir.exist?(@config.decisions_root)
|
|
569
|
+
|
|
570
|
+
on_disk = decisions.select { |r| r[:stage] == 'proposed' }.to_h { |r| [r[:id], r] }
|
|
571
|
+
lines = File.exist?(path) ? File.readlines(path, encoding: 'utf-8') : []
|
|
572
|
+
pattern = /\A\s*-\s+(#{Regexp.escape(@config.namespace)}-ADR-\d{4})/
|
|
573
|
+
|
|
574
|
+
kept = lines.select { |l| (id = l[pattern, 1]).nil? || on_disk.key?(id) }
|
|
575
|
+
listed = kept.filter_map { |l| l[pattern, 1] }
|
|
576
|
+
adds = (on_disk.keys - listed).map do |id|
|
|
577
|
+
"- #{id} - #{decision_title(on_disk[id])}\n"
|
|
578
|
+
end
|
|
579
|
+
atomic_write(path, (kept + adds).join) if kept != lines || adds.any?
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# The '# heading' is the title; BT-ADR-0014 keeps no title key precisely so
|
|
583
|
+
# the two cannot drift.
|
|
584
|
+
def decision_title(record)
|
|
585
|
+
record[:body][/^#\s+(.+)$/, 1] || record[:filename]
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Stories whose frontmatter status disagrees with their stage directory
|
|
589
|
+
# (the directory is authoritative). Surfaced by rake story:lint.
|
|
590
|
+
def status_drift
|
|
591
|
+
all_stories.filter_map do |s|
|
|
592
|
+
declared = s[:declared_status]
|
|
593
|
+
actual = STATUS_MAP[s[:stage]]
|
|
594
|
+
next if declared.nil? || declared == actual
|
|
595
|
+
|
|
596
|
+
{ id: s[:id], declared: declared, actual: actual, path: s[:path] }
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Relationship findings whose kind is a flow signal rather than corruption -
|
|
601
|
+
# reported by story:lint, but never on their own a reason to exit non-zero.
|
|
602
|
+
RELATIONSHIP_WARNINGS = %i[started_while_blocked].freeze
|
|
603
|
+
|
|
604
|
+
# Findings over the blocked_by/linked_to graph (BT-121). Surfaced by
|
|
605
|
+
# rake story:lint, which decides which kinds fail a build.
|
|
606
|
+
#
|
|
607
|
+
# Two deliberate exemptions keep this actionable instead of merely loud:
|
|
608
|
+
#
|
|
609
|
+
# Only tokens shaped like a story ID for THIS namespace are treated as
|
|
610
|
+
# references. `blocked_by` legitimately names things outside the tracker -
|
|
611
|
+
# BT-024 waits on `sinatra-5.x` - and calling those dangling would punish a
|
|
612
|
+
# real use of the field. An ID from another namespace reads as external for
|
|
613
|
+
# the same reason: this tracker cannot resolve it either way.
|
|
614
|
+
#
|
|
615
|
+
# Stories in 4_done are never the SUBJECT of a finding. Done is append-only
|
|
616
|
+
# (see docs/flow.md), so their relationships are historical record, not an
|
|
617
|
+
# actionable state - and reporting them would make lint noisier with every
|
|
618
|
+
# story that ships. Done stories still count as the OBJECT of a reference:
|
|
619
|
+
# that is exactly what #stale_blocker detects.
|
|
620
|
+
def relationship_findings
|
|
621
|
+
stories = all_stories
|
|
622
|
+
by_id = stories.to_h { |s| [s[:id], s] }
|
|
623
|
+
live = stories.reject { |s| s[:stage] == '4_done' }
|
|
624
|
+
exact = /\A#{id_pattern}\z/
|
|
625
|
+
|
|
626
|
+
findings = live.sort_by { |s| s[:id] }.flat_map do |s|
|
|
627
|
+
blocker_findings(s, by_id, exact) + dangling_links(s, by_id, exact)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
findings + blocker_cycles(live, by_id)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# A story's blocked_by entries, classified. Order matters: a missing
|
|
634
|
+
# blocker can't also be done, and a done blocker makes "started while
|
|
635
|
+
# blocked" moot - the story isn't really blocked, its frontmatter is stale.
|
|
636
|
+
def blocker_findings(story, by_id, exact)
|
|
637
|
+
story[:blocked_by].select { |r| r.match?(exact) }.filter_map do |bid|
|
|
638
|
+
blocker = by_id[bid]
|
|
639
|
+
kind =
|
|
640
|
+
if blocker.nil? then :dangling
|
|
641
|
+
elsif blocker[:stage] == '4_done' then :stale_blocker
|
|
642
|
+
elsif story[:stage] == '3_started' then :started_while_blocked
|
|
643
|
+
end
|
|
644
|
+
next unless kind
|
|
645
|
+
|
|
646
|
+
{ kind: kind, id: story[:id], ref: bid, field: 'blocked_by',
|
|
647
|
+
ref_stage: blocker && blocker[:stage], path: story[:path] }
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def dangling_links(story, by_id, exact)
|
|
652
|
+
story[:linked_to].select { |r| r.match?(exact) }.reject { |lid| by_id.key?(lid) }.map do |lid|
|
|
653
|
+
{ kind: :dangling, id: story[:id], ref: lid, field: 'linked_to',
|
|
654
|
+
ref_stage: nil, path: story[:path] }
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Cycles in the blocked_by graph - A waits on B waits on A, or a story that
|
|
659
|
+
# names itself. Nothing can ever start, so this is a deadlock the tracker
|
|
660
|
+
# should refuse to keep quiet about. Each cycle is rotated to begin at its
|
|
661
|
+
# lowest ID, so reaching the same cycle from two entry points reports once.
|
|
662
|
+
def blocker_cycles(live, by_id)
|
|
663
|
+
graph = live.to_h { |s| [s[:id], s[:blocked_by].select { |b| by_id.key?(b) }] }
|
|
664
|
+
state = {}
|
|
665
|
+
path = []
|
|
666
|
+
found = {}
|
|
667
|
+
|
|
668
|
+
visit = lambda do |id|
|
|
669
|
+
return if state[id] == :done
|
|
670
|
+
|
|
671
|
+
if state[id] == :visiting
|
|
672
|
+
cycle = path[path.index(id)..]
|
|
673
|
+
found[cycle.rotate(cycle.index(cycle.min))] = true
|
|
674
|
+
return
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
state[id] = :visiting
|
|
678
|
+
path.push(id)
|
|
679
|
+
graph[id].each { |bid| visit.call(bid) if graph.key?(bid) }
|
|
680
|
+
path.pop
|
|
681
|
+
state[id] = :done
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
graph.each_key { |id| visit.call(id) }
|
|
685
|
+
found.keys.sort.map { |c| { kind: :cycle, id: c.first, cycle: c, path: by_id[c.first][:path] } }
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# The story ID of a backlog ENTRY line ("- NS-123 title"). Anchored to the
|
|
689
|
+
# entry format so an ID merely mentioned in a comment/heading/prose line
|
|
690
|
+
# ("<!-- NS-123 archived -->") is not treated as a backlog member - which
|
|
691
|
+
# made story:lint report false phantoms (BT-107). Returns nil for non-entry
|
|
692
|
+
# lines. (Filenames use filename_id_pattern, not this.)
|
|
693
|
+
def backlog_line_id(line)
|
|
694
|
+
m = line.match(/\A\s*-\s+(#{id_pattern})/)
|
|
695
|
+
m && m[1]
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# ── Decisions (BT-ADR-0014) ───────────────────────────────────────────────
|
|
699
|
+
# A record is <NS>-ADR-NNNN-slug.md inside a status directory. The directory
|
|
700
|
+
# is the source of truth for status; frontmatter mirrors it (0006's rule,
|
|
701
|
+
# applied to decisions). Everything else in decisions_root is not a record.
|
|
702
|
+
|
|
703
|
+
def decision_record_pattern
|
|
704
|
+
@decision_record_pattern ||=
|
|
705
|
+
/\A#{Regexp.escape(@config.namespace)}-ADR-(\d{4})-.+\.md\z/
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Every .md in a status directory, whether or not it is a valid record -
|
|
709
|
+
# the invalid ones are what the "not-a-record" warning is for.
|
|
710
|
+
def decision_files
|
|
711
|
+
root = @config.decisions_root
|
|
712
|
+
return [] unless root && Dir.exist?(root)
|
|
713
|
+
|
|
714
|
+
STATUSES.flat_map do |status|
|
|
715
|
+
safe_glob(File.join(root, status), '*.md').map { |p| [status, p] }
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def decisions
|
|
720
|
+
decision_files.filter_map do |status, path|
|
|
721
|
+
name = File.basename(path)
|
|
722
|
+
m = name.match(decision_record_pattern)
|
|
723
|
+
next unless m
|
|
724
|
+
|
|
725
|
+
fm, body = parse_decision_file(path)
|
|
726
|
+
{
|
|
727
|
+
id: "#{@config.namespace}-ADR-#{m[1]}",
|
|
728
|
+
number: m[1].to_i,
|
|
729
|
+
stage: status,
|
|
730
|
+
declared: fm['status']&.to_s,
|
|
731
|
+
date: fm['date'].to_s,
|
|
732
|
+
fm: fm,
|
|
733
|
+
body: body,
|
|
734
|
+
path: path,
|
|
735
|
+
filename: name,
|
|
736
|
+
frontmatter?: File.read(path, encoding: 'utf-8').start_with?("---\n")
|
|
737
|
+
}
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# The board's read shape (BT-144): one entry per record, frontmatter
|
|
742
|
+
# lifted, title from the # heading (BT-ADR-0014 keeps no title key so the
|
|
743
|
+
# two cannot drift), docs_path present when the record is reachable
|
|
744
|
+
# through the docs surface for the editor/reveal actions.
|
|
745
|
+
def decisions_board
|
|
746
|
+
docs_base = @config.docs_root && File.expand_path(@config.docs_root)
|
|
747
|
+
order = proposed_order
|
|
748
|
+
sorted = decisions.sort_by do |r|
|
|
749
|
+
# Proposed records follow proposed.md - position is the priority
|
|
750
|
+
# (BT-147); every other status reads by id.
|
|
751
|
+
r[:stage] == 'proposed' ? [0, order.index(r[:id]) || order.size, r[:number]] : [1, r[:number], 0]
|
|
752
|
+
end
|
|
753
|
+
sorted.map do |r|
|
|
754
|
+
expanded = File.expand_path(r[:path])
|
|
755
|
+
{
|
|
756
|
+
id: r[:id],
|
|
757
|
+
status: r[:stage],
|
|
758
|
+
title: r[:body][/^#\s+(.+)$/, 1] || r[:filename],
|
|
759
|
+
date: r[:date],
|
|
760
|
+
supersedes: decision_refs(r[:fm]['supersedes']),
|
|
761
|
+
superseded_by: decision_refs(r[:fm]['superseded_by']),
|
|
762
|
+
stories: decision_refs(r[:fm]['stories']),
|
|
763
|
+
canonical: r[:fm]['canonical'].to_s.then { |c| c.empty? ? nil : c },
|
|
764
|
+
docs_path: if docs_base && expanded.start_with?(docs_base + File::SEPARATOR)
|
|
765
|
+
expanded.delete_prefix(docs_base + File::SEPARATOR)
|
|
766
|
+
end
|
|
767
|
+
}
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Lint findings over the decision corpus. Severity follows 0013: integrity
|
|
772
|
+
# fails the build, hygiene reports and exits clean. Ids outside this
|
|
773
|
+
# project's namespace are never resolved - this checkout cannot settle them
|
|
774
|
+
# either way, so it must not claim they are broken.
|
|
775
|
+
def decision_findings
|
|
776
|
+
root = @config.decisions_root
|
|
777
|
+
return [] unless root && Dir.exist?(root)
|
|
778
|
+
|
|
779
|
+
records = decisions
|
|
780
|
+
out = []
|
|
781
|
+
f = ->(kind, id, msg, path) { out << { kind: kind, severity: :failure, id: id, message: msg, path: path } }
|
|
782
|
+
w = ->(kind, id, msg, path) { out << { kind: kind, severity: :warning, id: id, message: msg, path: path } }
|
|
783
|
+
|
|
784
|
+
decision_files.each do |_status, path|
|
|
785
|
+
name = File.basename(path)
|
|
786
|
+
next if name.match?(decision_record_pattern)
|
|
787
|
+
|
|
788
|
+
w.call('not-a-record', nil,
|
|
789
|
+
"#{name} is not a record filename (<NS>-ADR-NNNN-slug.md) - a forgotten id?", path)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
by_number = records.group_by { |r| r[:number] }
|
|
793
|
+
by_number.each do |num, group|
|
|
794
|
+
next if group.size < 2
|
|
795
|
+
|
|
796
|
+
f.call('duplicate-id', format('%s-ADR-%04d', @config.namespace, num),
|
|
797
|
+
"id used by #{group.size} records: #{group.map { |r| r[:filename] }.join(', ')}",
|
|
798
|
+
group.first[:path])
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
ids = records.map { |r| r[:id] }.to_set
|
|
802
|
+
|
|
803
|
+
records.each do |r|
|
|
804
|
+
# A record with no frontmatter yields one finding and suppresses the
|
|
805
|
+
# per-key checks (BT-ADR-0014 amendment) - three findings for one cause
|
|
806
|
+
# is noise, and BT-130's acceptance count assumes this.
|
|
807
|
+
unless r[:frontmatter?]
|
|
808
|
+
f.call('no-frontmatter', r[:id], 'no YAML frontmatter block', r[:path])
|
|
809
|
+
next
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
if r[:declared].nil? || r[:declared].empty?
|
|
813
|
+
f.call('missing-status', r[:id], 'frontmatter has no status', r[:path])
|
|
814
|
+
elsif !STATUSES.include?(r[:declared])
|
|
815
|
+
f.call('unknown-status', r[:id], "status #{r[:declared].inspect} is not one of #{STATUSES.join(', ')}", r[:path])
|
|
816
|
+
elsif r[:declared] != r[:stage]
|
|
817
|
+
f.call('status-drift', r[:id],
|
|
818
|
+
"status says #{r[:declared]} but the record is in #{r[:stage]}/ - the directory is authoritative", r[:path])
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
if r[:date].empty?
|
|
822
|
+
f.call('missing-date', r[:id], 'frontmatter has no date', r[:path])
|
|
823
|
+
elsif !r[:date].match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
824
|
+
f.call('bad-date', r[:id], "date #{r[:date].inspect} is not ISO-8601 (YYYY-MM-DD)", r[:path])
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
%w[supersedes superseded_by].each do |field|
|
|
828
|
+
decision_refs(r[:fm][field]).each do |ref|
|
|
829
|
+
next unless ref.start_with?("#{@config.namespace}-ADR-")
|
|
830
|
+
next f.call('dangling-ref', r[:id], "#{field} names #{ref}, which does not exist", r[:path]) unless ids.include?(ref)
|
|
831
|
+
|
|
832
|
+
other = records.find { |x| x[:id] == ref }
|
|
833
|
+
mirror = field == 'supersedes' ? 'superseded_by' : 'supersedes'
|
|
834
|
+
unless decision_refs(other[:fm][mirror]).include?(r[:id])
|
|
835
|
+
f.call('asymmetric-supersession', r[:id],
|
|
836
|
+
"#{field} names #{ref}, but #{ref} does not name it back in #{mirror}", r[:path])
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
if r[:stage] == 'superseded' && decision_refs(r[:fm]['superseded_by']).empty?
|
|
842
|
+
w.call('no-successor', r[:id], 'superseded with an empty superseded_by', r[:path])
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
canonical = r[:fm]['canonical'].to_s
|
|
846
|
+
if canonical.start_with?("#{@config.namespace}-ADR-")
|
|
847
|
+
w.call('own-canonical', r[:id],
|
|
848
|
+
'canonical points inside this project - an adoption should defer to another project', r[:path])
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
prose = r[:body][/^##\s+Status\s*\n+(\S+)/, 1]
|
|
852
|
+
if prose && prose.downcase.delete('^a-z') != r[:stage]
|
|
853
|
+
w.call('prose-drift', r[:id],
|
|
854
|
+
"the ## Status prose says #{prose.inspect} but the record is in #{r[:stage]}/", r[:path])
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
out.concat(proposed_findings(records))
|
|
859
|
+
|
|
860
|
+
max = records.map { |r| r[:number] }.max
|
|
861
|
+
if max
|
|
862
|
+
nxt = File.exist?(@config.decisions_next_id_path) ? File.read(@config.decisions_next_id_path).strip.to_i : 0
|
|
863
|
+
if nxt <= max
|
|
864
|
+
f.call('stale-next-id', nil,
|
|
865
|
+
"#{'.next-id'} is #{nxt}, at or below the highest id on disk (#{max})",
|
|
866
|
+
@config.decisions_next_id_path)
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
out
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# Decisions parse their own frontmatter: parse_story_file requires a story-id
|
|
874
|
+
# filename match, which an <NS>-ADR-NNNN name never satisfies.
|
|
875
|
+
def parse_decision_file(path)
|
|
876
|
+
content = File.read(path, encoding: 'utf-8')
|
|
877
|
+
return [{}, content] unless content.start_with?("---\n")
|
|
878
|
+
|
|
879
|
+
parts = content.split(/^---\n/, 3)
|
|
880
|
+
fm = begin
|
|
881
|
+
YAML.safe_load(parts[1].to_s, permitted_classes: [Date, Time])
|
|
882
|
+
rescue Psych::Exception => e
|
|
883
|
+
warn "[BaconTracker] ignoring malformed frontmatter in #{File.basename(path)}: #{e.message}"
|
|
884
|
+
nil
|
|
885
|
+
end
|
|
886
|
+
fm = {} unless fm.is_a?(Hash)
|
|
887
|
+
[fm.transform_keys(&:to_s), parts[2].to_s.lstrip]
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# A frontmatter list value, tolerating the scalar and comma-separated forms
|
|
891
|
+
# the field grammar already accepts elsewhere (0009) and a hand-authored
|
|
892
|
+
# YAML list (`blocked_by: [A, B]`). Shared by stories and decisions.
|
|
893
|
+
def decision_refs(value)
|
|
894
|
+
case value
|
|
895
|
+
when Array then value.map { |v| v.to_s.strip }.reject(&:empty?)
|
|
896
|
+
when nil then []
|
|
897
|
+
else value.to_s.split(/\s*,\s*/).map(&:strip).reject(&:empty?)
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def proposed_findings(records)
|
|
902
|
+
path = @config.proposed_path
|
|
903
|
+
listed = if File.exist?(path)
|
|
904
|
+
File.readlines(path, encoding: 'utf-8').filter_map do |line|
|
|
905
|
+
line[/\A\s*-\s+(#{Regexp.escape(@config.namespace)}-ADR-\d{4})/, 1]
|
|
906
|
+
end
|
|
907
|
+
else
|
|
908
|
+
[]
|
|
909
|
+
end
|
|
910
|
+
in_proposed = records.select { |r| r[:stage] == 'proposed' }.map { |r| r[:id] }
|
|
911
|
+
|
|
912
|
+
(listed - records.map { |r| r[:id] }).map do |ghost|
|
|
913
|
+
{ kind: 'phantom-entry', severity: :failure, id: ghost,
|
|
914
|
+
message: "proposed.md lists #{ghost}, which does not exist", path: path }
|
|
915
|
+
end +
|
|
916
|
+
(listed & records.map { |r| r[:id] } - in_proposed).map do |moved|
|
|
917
|
+
{ kind: 'listed-not-proposed', severity: :failure, id: moved,
|
|
918
|
+
message: "proposed.md lists #{moved}, which is no longer in proposed/", path: path }
|
|
919
|
+
end +
|
|
920
|
+
(in_proposed - listed).map do |missing|
|
|
921
|
+
{ kind: 'unlisted', severity: :failure, id: missing,
|
|
922
|
+
message: "#{missing} is in proposed/ but missing from proposed.md", path: path }
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# Extensions a docs page can have. Markdown only is the product decision;
|
|
927
|
+
# txt rides along as the one plain form worth rendering (§13.2).
|
|
928
|
+
DOC_EXTENSIONS = %w[.md .markdown .txt].freeze
|
|
929
|
+
|
|
930
|
+
# A file is a page if it renders in the docs browser: right extension, not
|
|
931
|
+
# a dotfile, not `_`-hidden, and not part of a tracked subtree - decision
|
|
932
|
+
# records are counted as decisions, and a tracker tree living inside the
|
|
933
|
+
# docs tree is work, not documentation (BT-ADR-0016).
|
|
934
|
+
def docs_pages
|
|
935
|
+
root = @config.docs_root
|
|
936
|
+
return [] unless root && Dir.exist?(root)
|
|
937
|
+
|
|
938
|
+
# The tracker tree is excluded only when it sits INSIDE the docs tree -
|
|
939
|
+
# BT-ADR-0016's derived exclusion. The other direction (a docs folder
|
|
940
|
+
# under a broad tracker_root) must not erase the docs tree.
|
|
941
|
+
expanded = File.expand_path(root)
|
|
942
|
+
tracker = @config.tracker_root && File.expand_path(@config.tracker_root)
|
|
943
|
+
skip_roots = [@config.decisions_root && File.expand_path(@config.decisions_root),
|
|
944
|
+
(tracker if tracker&.start_with?(expanded + File::SEPARATOR))]
|
|
945
|
+
.compact.map { |r| r + File::SEPARATOR }
|
|
946
|
+
|
|
947
|
+
Dir.glob(File.join(escape_glob(root), '**', '*'))
|
|
948
|
+
.select { |f| File.file?(f) && DOC_EXTENSIONS.include?(File.extname(f).downcase) }
|
|
949
|
+
.reject { |f| File.basename(f).start_with?('.', '_') }
|
|
950
|
+
.reject { |f| skip_roots.any? { |sr| File.expand_path(f).start_with?(sr) } }
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# Recently changed pages (BT-151), from git history rather than mtime - a
|
|
954
|
+
# checkout touches every file's mtime without any of them having changed.
|
|
955
|
+
# One log pass newest-first; the first time a path appears is its latest
|
|
956
|
+
# change. No repository (or git absent) is an empty list, never an error.
|
|
957
|
+
RECENT_LIMIT = 20
|
|
958
|
+
|
|
959
|
+
def docs_recent
|
|
960
|
+
root = @config.docs_root
|
|
961
|
+
return [] unless root && Dir.exist?(root)
|
|
962
|
+
|
|
963
|
+
out = `git -C #{Shellwords.shellescape(root)} log --format=%x00%at --name-only --relative 2>/dev/null`
|
|
964
|
+
return [] if out.empty?
|
|
965
|
+
|
|
966
|
+
visible = visible_docs_set
|
|
967
|
+
seen = {}
|
|
968
|
+
at = nil
|
|
969
|
+
out.each_line(chomp: true) do |line|
|
|
970
|
+
if line.start_with?("\x00")
|
|
971
|
+
at = line.delete_prefix("\x00").to_i
|
|
972
|
+
elsif !line.empty? && !seen.key?(line) && visible.include?(line)
|
|
973
|
+
seen[line] = at
|
|
974
|
+
break if seen.size >= RECENT_LIMIT
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
seen.map { |path, ts| { path: path, at: ts, date: Time.at(ts).strftime('%Y-%m-%d') } }
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# What links here (BT-152): every visible page whose relative markdown
|
|
981
|
+
# links resolve to the target, plus - when the target is a decision record
|
|
982
|
+
# - every page citing its id. A grep, which is the thing a filesystem tool
|
|
983
|
+
# can do that Confluence never could.
|
|
984
|
+
def docs_backlinks(target_rel)
|
|
985
|
+
root = @config.docs_root
|
|
986
|
+
return [] unless root && Dir.exist?(root)
|
|
987
|
+
|
|
988
|
+
base = File.expand_path(root)
|
|
989
|
+
target = File.expand_path(target_rel, base).delete_prefix(base + File::SEPARATOR)
|
|
990
|
+
target_id = File.basename(target)[/\A([A-Z][A-Z0-9]*-ADR-\d{4})/, 1]
|
|
991
|
+
|
|
992
|
+
all_docs_files.filter_map do |file|
|
|
993
|
+
rel = File.expand_path(file).delete_prefix(base + File::SEPARATOR)
|
|
994
|
+
next if rel == target
|
|
995
|
+
|
|
996
|
+
content = File.read(file, encoding: 'utf-8')
|
|
997
|
+
linked = content.scan(/\]\(([^)#\s]+)\)/).flatten.any? do |href|
|
|
998
|
+
next false if href.match?(%r{\A[a-z]+:|\A/})
|
|
999
|
+
|
|
1000
|
+
File.expand_path(href, File.dirname(File.join(base, rel)))
|
|
1001
|
+
.delete_prefix(base + File::SEPARATOR) == target
|
|
1002
|
+
end
|
|
1003
|
+
cited = target_id && content.include?(target_id)
|
|
1004
|
+
{ path: rel } if linked || cited
|
|
1005
|
+
rescue ArgumentError
|
|
1006
|
+
nil
|
|
1007
|
+
end
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
def all_docs_files
|
|
1011
|
+
docs_pages + (@config.decisions_root ? decisions.map { |r| r[:path] } : [])
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
def visible_docs_set
|
|
1015
|
+
base = File.expand_path(@config.docs_root)
|
|
1016
|
+
all_docs_files.map { |f| File.expand_path(f).delete_prefix(base + File::SEPARATOR) }.to_set
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# Full-text search over the docs surface (BT-150): the pages the tree
|
|
1020
|
+
# lists plus the decision records - never hidden files, since docs_pages
|
|
1021
|
+
# and decisions already carry the visibility rules. Pure Ruby by choice:
|
|
1022
|
+
# the corpora are dozens of files, so no shell-out and no dependency; the
|
|
1023
|
+
# scan is trivially swappable if a corpus ever outgrows it.
|
|
1024
|
+
SEARCH_PER_FILE = 3
|
|
1025
|
+
SEARCH_TOTAL = 100
|
|
1026
|
+
|
|
1027
|
+
def docs_search(query)
|
|
1028
|
+
q = query.to_s.strip.downcase
|
|
1029
|
+
return [] if q.empty?
|
|
1030
|
+
|
|
1031
|
+
docs_base = File.expand_path(@config.docs_root.to_s)
|
|
1032
|
+
files = docs_pages +
|
|
1033
|
+
(@config.decisions_root ? decisions.map { |r| r[:path] } : [])
|
|
1034
|
+
hits = []
|
|
1035
|
+
files.each do |file|
|
|
1036
|
+
break if hits.size >= SEARCH_TOTAL
|
|
1037
|
+
|
|
1038
|
+
rel = File.expand_path(file).delete_prefix(docs_base + File::SEPARATOR)
|
|
1039
|
+
per = 0
|
|
1040
|
+
File.foreach(file, encoding: 'utf-8').with_index(1) do |line, no|
|
|
1041
|
+
next unless line.downcase.include?(q)
|
|
1042
|
+
|
|
1043
|
+
hits << { path: rel, folder: File.dirname(rel), lineno: no, line: line.strip[0, 200] }
|
|
1044
|
+
per += 1
|
|
1045
|
+
break if per >= SEARCH_PER_FILE || hits.size >= SEARCH_TOTAL
|
|
1046
|
+
end
|
|
1047
|
+
rescue ArgumentError
|
|
1048
|
+
next # undecodable bytes in one file must not kill the search
|
|
1049
|
+
end
|
|
1050
|
+
hits
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# The identity files at the project root (BT-149). Fixed basenames only -
|
|
1054
|
+
# no caller-supplied path ever reaches this, so there is nothing to scope.
|
|
1055
|
+
# Every key is optional and nil when absent, per the missing-file posture.
|
|
1056
|
+
def project_front
|
|
1057
|
+
root = @config.project_root
|
|
1058
|
+
return {} unless root && Dir.exist?(root)
|
|
1059
|
+
|
|
1060
|
+
readme = File.join(root, 'README.md')
|
|
1061
|
+
changelog = File.join(root, 'CHANGELOG.md')
|
|
1062
|
+
version, ambiguous = resolve_version(root)
|
|
1063
|
+
{
|
|
1064
|
+
readme_html: (render_markdown(File.read(readme, encoding: 'utf-8')) if File.file?(readme)),
|
|
1065
|
+
changelog_html: (render_markdown(File.read(changelog, encoding: 'utf-8')) if File.file?(changelog)),
|
|
1066
|
+
version: version,
|
|
1067
|
+
version_ambiguous: ambiguous
|
|
1068
|
+
}
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def render_markdown(text)
|
|
1072
|
+
doc = Kramdown::Document.new(gfm_table_compat(text), input: 'GFM', hard_wrap: false)
|
|
1073
|
+
sanitize_tree!(doc.root)
|
|
1074
|
+
doc.to_html
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
# Rendered markdown lands in a page that can call the write API, so it is
|
|
1078
|
+
# sanitised before it leaves the server (BT-179): kramdown passes raw HTML
|
|
1079
|
+
# through verbatim, and a `<img onerror>` or `[x](javascript:)` in a
|
|
1080
|
+
# teammate's page would run on the board's origin. kramdown has no
|
|
1081
|
+
# sanitiser of its own; this walks its element tree instead. Raw HTML
|
|
1082
|
+
# elements and {::nomarkdown} spans are dropped whole - markdown is the
|
|
1083
|
+
# product decision (BT-ADR-0018), and an allowlist would be a second one.
|
|
1084
|
+
UNSAFE_ELEMENT_TYPES = %i[html_element xml_pi xml_comment raw].freeze
|
|
1085
|
+
URL_ATTRIBUTES = %w[href src].freeze
|
|
1086
|
+
SAFE_URL_SCHEMES = %w[http https mailto].freeze
|
|
1087
|
+
CHECKBOX_ATTRIBUTES = %w[class checked].freeze
|
|
1088
|
+
|
|
1089
|
+
def sanitize_tree!(el)
|
|
1090
|
+
el.children.reject! { |c| UNSAFE_ELEMENT_TYPES.include?(c.type) && !task_checkbox!(c) }
|
|
1091
|
+
# IAL syntax ({: onclick="..."}) can plant any attribute on any element.
|
|
1092
|
+
el.attr.delete_if { |k, _| k.start_with?('on') || k == 'style' }
|
|
1093
|
+
URL_ATTRIBUTES.each { |a| el.attr.delete(a) if el.attr.key?(a) && !safe_url?(el.attr[a]) }
|
|
1094
|
+
el.children.each { |c| sanitize_tree!(c) }
|
|
1095
|
+
el
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# The GFM parser renders "- [ ]" as an html_element input - the one raw
|
|
1099
|
+
# element the docs surface needs. Keep it, rebuilt as an inert, disabled
|
|
1100
|
+
# checkbox with nothing an author could have added (true = keep).
|
|
1101
|
+
def task_checkbox!(el)
|
|
1102
|
+
return false unless el.type == :html_element && el.value == 'input' && el.attr['type'] == 'checkbox'
|
|
1103
|
+
|
|
1104
|
+
el.attr.keep_if { |k, _| CHECKBOX_ATTRIBUTES.include?(k) }
|
|
1105
|
+
el.attr['type'] = 'checkbox'
|
|
1106
|
+
el.attr['disabled'] = 'disabled'
|
|
1107
|
+
el.children.clear
|
|
1108
|
+
true
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# Relative, anchor and absolute-path URLs have no scheme and pass; a
|
|
1112
|
+
# scheme must be one of the three. Control characters are stripped first
|
|
1113
|
+
# because browsers ignore them inside a scheme ("java\tscript:").
|
|
1114
|
+
def safe_url?(url)
|
|
1115
|
+
scheme = url.to_s.gsub(/[\x00-\x20]/, '')[/\A([a-z][a-z0-9+.-]*):/i, 1]
|
|
1116
|
+
scheme.nil? || SAFE_URL_SCHEMES.include?(scheme.downcase)
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
# kramdown's GFM parser demands a blank line before a table; GitHub's does
|
|
1120
|
+
# not, and the corpus is written against GitHub's (five of drop's eight
|
|
1121
|
+
# README tables sit flush under a heading - GitHub-legal, kramdown-
|
|
1122
|
+
# invisible). Insert the blank line kramdown wants, fence-aware so pipe
|
|
1123
|
+
# art inside code blocks is never touched (BT-173).
|
|
1124
|
+
def gfm_table_compat(text)
|
|
1125
|
+
lines = text.lines
|
|
1126
|
+
out = []
|
|
1127
|
+
fence = nil
|
|
1128
|
+
lines.each_with_index do |line, i|
|
|
1129
|
+
if (m = line.match(/\A(`{3,}|~{3,})/))
|
|
1130
|
+
fence = fence.nil? ? m[1][0] * 3 : nil
|
|
1131
|
+
elsif fence.nil? &&
|
|
1132
|
+
line.match?(/\A\s*\|/) &&
|
|
1133
|
+
lines[i + 1]&.match?(/\A\s*\|?[ :|-]*-[ :|-]*\|?\s*\z/) &&
|
|
1134
|
+
out.last && !out.last.strip.empty? && !out.last.match?(/\A\s*\|/)
|
|
1135
|
+
out << "\n"
|
|
1136
|
+
end
|
|
1137
|
+
out << line
|
|
1138
|
+
end
|
|
1139
|
+
out.join
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
# VERSION discovery (BT-162, settled): an explicit version_path overrides
|
|
1143
|
+
# everything; otherwise ./VERSION, then */VERSION one level deep. Two
|
|
1144
|
+
# candidates and no override is an ambiguity to report, never a guess.
|
|
1145
|
+
def resolve_version(root)
|
|
1146
|
+
if @config.version_path
|
|
1147
|
+
path = File.expand_path(@config.version_path, root)
|
|
1148
|
+
return [File.file?(path) ? File.read(path).strip : nil, nil]
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
direct = File.join(root, 'VERSION')
|
|
1152
|
+
return [File.read(direct).strip, nil] if File.file?(direct)
|
|
1153
|
+
|
|
1154
|
+
candidates = safe_glob(root, '*/VERSION').select { |f| File.file?(f) }.sort
|
|
1155
|
+
case candidates.size
|
|
1156
|
+
when 0 then [nil, nil]
|
|
1157
|
+
when 1 then [File.read(candidates.first).strip, nil]
|
|
1158
|
+
else [nil, candidates.map { |f| f.delete_prefix(File.expand_path(root) + File::SEPARATOR) }]
|
|
1159
|
+
end
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
# The docs tree as nested nodes for the column browser. Same visibility
|
|
1163
|
+
# rules as docs_pages, plus: a tracked subtree shows its records but never
|
|
1164
|
+
# its machinery (proposed.md, .next-id, _template.md), and a directory with
|
|
1165
|
+
# nothing renderable in it is not a column entry at all.
|
|
1166
|
+
def docs_tree(dir = nil)
|
|
1167
|
+
root = @config.docs_root
|
|
1168
|
+
return [] unless root && Dir.exist?(root)
|
|
1169
|
+
|
|
1170
|
+
dir ||= File.expand_path(root)
|
|
1171
|
+
tracker = @config.tracker_root && File.expand_path(@config.tracker_root)
|
|
1172
|
+
|
|
1173
|
+
Dir.children(dir).sort.filter_map do |name|
|
|
1174
|
+
next if name.start_with?('.', '_')
|
|
1175
|
+
|
|
1176
|
+
full = File.join(dir, name)
|
|
1177
|
+
# docs_pages' glob does not follow symlinked directories; the tree must
|
|
1178
|
+
# not list what the page read would then refuse (BT-179).
|
|
1179
|
+
next if File.symlink?(full) && File.directory?(full)
|
|
1180
|
+
|
|
1181
|
+
if File.directory?(full)
|
|
1182
|
+
next if tracker && File.expand_path(full) == tracker &&
|
|
1183
|
+
tracker.start_with?(File.expand_path(@config.docs_root) + File::SEPARATOR)
|
|
1184
|
+
|
|
1185
|
+
children = docs_tree(full)
|
|
1186
|
+
next if children.empty?
|
|
1187
|
+
|
|
1188
|
+
node = { name: name, type: 'dir', path: relative_docs_path(full), children: children }
|
|
1189
|
+
# The decisions directory is a tracked subtree with its own board -
|
|
1190
|
+
# the browser sends it there instead of column-walking it (BT-176).
|
|
1191
|
+
node[:decisions] = true if @config.decisions_root &&
|
|
1192
|
+
File.expand_path(full) == File.expand_path(@config.decisions_root)
|
|
1193
|
+
node
|
|
1194
|
+
else
|
|
1195
|
+
next unless DOC_EXTENSIONS.include?(File.extname(name).downcase)
|
|
1196
|
+
next if in_decisions?(full) && name == 'proposed.md'
|
|
1197
|
+
|
|
1198
|
+
{ name: name, type: 'page', path: relative_docs_path(full) }
|
|
1199
|
+
end
|
|
1200
|
+
end
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
# Read one page by its docs-relative path. The scope check is the point:
|
|
1204
|
+
# this is a web-reachable file read (BT-159), so anything the tree would
|
|
1205
|
+
# not list - traversal, absolute paths, hidden files, non-page extensions -
|
|
1206
|
+
# raises without ever touching the file.
|
|
1207
|
+
def docs_page(rel)
|
|
1208
|
+
File.read(docs_file!(rel), encoding: 'utf-8')
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
# The one scope check for every docs-relative path a client can hand us -
|
|
1212
|
+
# page reads, editor opens, reveals. Anything the tree would not list
|
|
1213
|
+
# raises before the filesystem is touched (BT-159). allow_dir admits a
|
|
1214
|
+
# folder for reveal (BT-143); pages stay files with page extensions.
|
|
1215
|
+
def docs_file!(rel, allow_dir: false)
|
|
1216
|
+
root = @config.docs_root
|
|
1217
|
+
raise ArgumentError, 'no docs root configured' unless root && Dir.exist?(root)
|
|
1218
|
+
|
|
1219
|
+
expanded = File.expand_path(rel.to_s, root)
|
|
1220
|
+
base = File.expand_path(root)
|
|
1221
|
+
unless expanded.start_with?(base + File::SEPARATOR)
|
|
1222
|
+
raise ArgumentError, 'path is outside the docs root'
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
parts = expanded.delete_prefix(base + File::SEPARATOR).split(File::SEPARATOR)
|
|
1226
|
+
if parts.any? { |p| p.start_with?('.', '_') }
|
|
1227
|
+
raise ArgumentError, 'hidden files are not pages'
|
|
1228
|
+
end
|
|
1229
|
+
if File.basename(expanded) == 'proposed.md' && in_decisions?(expanded)
|
|
1230
|
+
raise ArgumentError, 'proposed.md is rendered by the board, not the browser'
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
# Only a tracker tree INSIDE the docs tree is excluded - the reverse
|
|
1234
|
+
# containment (a broad tracker root holding docs/) must not blank the
|
|
1235
|
+
# docs surface. Same direction rule as docs_pages.
|
|
1236
|
+
tracker = @config.tracker_root && File.expand_path(@config.tracker_root)
|
|
1237
|
+
if tracker && tracker.start_with?(base + File::SEPARATOR) &&
|
|
1238
|
+
expanded.start_with?(tracker + File::SEPARATOR)
|
|
1239
|
+
raise ArgumentError, 'the tracker tree is not documentation'
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
return within_docs!(expanded, base) if allow_dir && File.directory?(expanded)
|
|
1243
|
+
|
|
1244
|
+
unless DOC_EXTENSIONS.include?(File.extname(expanded).downcase)
|
|
1245
|
+
raise ArgumentError, 'not a renderable page'
|
|
1246
|
+
end
|
|
1247
|
+
raise ArgumentError, 'page not found' unless File.file?(expanded)
|
|
1248
|
+
|
|
1249
|
+
within_docs!(expanded, base)
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# The lexical check above is on the path as given; this one is on where it
|
|
1253
|
+
# really points, so a symlink inside docs/ cannot read outside it (BT-179).
|
|
1254
|
+
# The root is resolved too: /tmp is itself a link on macOS.
|
|
1255
|
+
def within_docs!(path, base)
|
|
1256
|
+
real_base = File.realpath(base)
|
|
1257
|
+
raise ArgumentError, 'path is outside the docs root' unless File.realpath(path).start_with?(real_base + File::SEPARATOR)
|
|
1258
|
+
|
|
1259
|
+
path
|
|
1260
|
+
rescue Errno::ENOENT, Errno::ELOOP
|
|
1261
|
+
raise ArgumentError, 'page not found'
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
# A page plus its rendered HTML. Markdown goes through kramdown's GFM
|
|
1265
|
+
# input (BT-ADR-0018) - task lists and tables are what the corpus actually
|
|
1266
|
+
# uses; txt is escaped verbatim, parsed as nothing.
|
|
1267
|
+
def render_page(rel)
|
|
1268
|
+
content = docs_page(rel)
|
|
1269
|
+
# Frontmatter is fields, never rendered YAML (BT-141) - any page may
|
|
1270
|
+
# carry a block, decision records always do.
|
|
1271
|
+
fm, body =
|
|
1272
|
+
if content.start_with?("---\n")
|
|
1273
|
+
parts = content.split(/^---\n/, 3)
|
|
1274
|
+
parsed = begin
|
|
1275
|
+
YAML.safe_load(parts[1].to_s, permitted_classes: [Date, Time])
|
|
1276
|
+
rescue Psych::Exception
|
|
1277
|
+
nil
|
|
1278
|
+
end
|
|
1279
|
+
# Stringify values too - YAML parses dates into Date objects, and the
|
|
1280
|
+
# JSON the client receives should carry the literal the file does.
|
|
1281
|
+
parsed.is_a?(Hash) ? [parsed.to_h { |k, v| [k.to_s, v.is_a?(Array) ? v.map(&:to_s) : v.to_s] }, parts[2].to_s.lstrip] : [{}, content]
|
|
1282
|
+
else
|
|
1283
|
+
[{}, content]
|
|
1284
|
+
end
|
|
1285
|
+
html =
|
|
1286
|
+
if File.extname(rel).downcase == '.txt'
|
|
1287
|
+
"<pre>#{body.gsub('&', '&').gsub('<', '<').gsub('>', '>')}</pre>"
|
|
1288
|
+
else
|
|
1289
|
+
render_markdown(body)
|
|
1290
|
+
end
|
|
1291
|
+
title = body[/^#\s+(.+)$/, 1] || File.basename(rel)
|
|
1292
|
+
{ path: rel, title: title, content: content, frontmatter: fm, html: html }
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
def relative_docs_path(full)
|
|
1296
|
+
File.expand_path(full).delete_prefix(File.expand_path(@config.docs_root) + File::SEPARATOR)
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
def in_decisions?(path)
|
|
1300
|
+
d = @config.decisions_root
|
|
1301
|
+
d && File.expand_path(path).start_with?(File.expand_path(d) + File::SEPARATOR)
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
def docs_stats
|
|
1305
|
+
records = @config.decisions_root ? decisions : []
|
|
1306
|
+
{
|
|
1307
|
+
pages: docs_pages.size,
|
|
1308
|
+
decisions: records.size,
|
|
1309
|
+
proposed: records.count { |r| r[:stage] == 'proposed' }
|
|
1310
|
+
}
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
# Create a proposed record from _template.md (BT-146): id from the locked
|
|
1314
|
+
# counter, filename per the contract, today's date and the title stamped
|
|
1315
|
+
# over the template's placeholders so the record is lint-clean from birth.
|
|
1316
|
+
# Web-reachable - raises, never aborts (BT-ADR-0005).
|
|
1317
|
+
def create_decision(title)
|
|
1318
|
+
title = title.to_s.strip
|
|
1319
|
+
raise ArgumentError, 'a decision needs a title' if title.empty?
|
|
1320
|
+
|
|
1321
|
+
root = @config.decisions_root
|
|
1322
|
+
raise ArgumentError, 'no decisions root configured' unless root
|
|
1323
|
+
|
|
1324
|
+
with_decisions_lock do
|
|
1325
|
+
heal_proposed!
|
|
1326
|
+
id = format_decision_id(consume_decision_id)
|
|
1327
|
+
slug = filename_slug(title) # never "<id>-.md", which is not a record
|
|
1328
|
+
template_path = File.join(root, '_template.md')
|
|
1329
|
+
body = File.exist?(template_path) ? File.read(template_path, encoding: 'utf-8') : Templates::DECISION
|
|
1330
|
+
today = Date.today.iso8601
|
|
1331
|
+
# Block form: a title containing \0 or \& must land verbatim, not as a
|
|
1332
|
+
# backreference (BT-179).
|
|
1333
|
+
body = body.gsub(/^date: .*$/, "date: #{today}")
|
|
1334
|
+
.gsub(/^- Date: .*$/, "- Date: #{today}")
|
|
1335
|
+
.sub(/^# .*$/) { "# #{title}" }
|
|
1336
|
+
|
|
1337
|
+
FileUtils.mkdir_p(File.join(root, 'proposed'))
|
|
1338
|
+
path = File.join(root, 'proposed', "#{id}-#{slug}.md")
|
|
1339
|
+
atomic_write(path, body)
|
|
1340
|
+
heal_proposed!
|
|
1341
|
+
path
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
# Allowed decision transitions (BT-ADR-0017), keyed by destination. Forward
|
|
1346
|
+
# only: nothing returns to proposed - reversing a decision means superseding
|
|
1347
|
+
# it with a new record - and rejected/deprecated/superseded are terminal.
|
|
1348
|
+
DECISION_TRANSITIONS = {
|
|
1349
|
+
'accepted' => %w[proposed],
|
|
1350
|
+
'rejected' => %w[proposed],
|
|
1351
|
+
'deprecated' => %w[accepted],
|
|
1352
|
+
'superseded' => %w[accepted]
|
|
1353
|
+
}.freeze
|
|
1354
|
+
|
|
1355
|
+
# The one transition primitive. Every surface - board, API, Rake, command -
|
|
1356
|
+
# funnels here, so none of them can disagree about what a decision means.
|
|
1357
|
+
# Web-reachable: raises ArgumentError, never aborts (BT-ADR-0005).
|
|
1358
|
+
#
|
|
1359
|
+
# Returns { id:, status:, external: } - :external lists a superseding id in
|
|
1360
|
+
# another namespace whose reciprocal side was NOT written, because this
|
|
1361
|
+
# checkout cannot edit another repository (BT-ADR-0013's rule applied to
|
|
1362
|
+
# writes). The caller is told rather than the other half being pretended.
|
|
1363
|
+
def set_status(id, new_status, superseded_by: nil)
|
|
1364
|
+
unless STATUSES.include?(new_status)
|
|
1365
|
+
raise ArgumentError, "Invalid status: #{new_status} (one of: #{STATUSES.join(', ')})"
|
|
1366
|
+
end
|
|
1367
|
+
if new_status == 'proposed'
|
|
1368
|
+
raise ArgumentError, 'nothing returns to proposed - supersede with a new record instead.'
|
|
1369
|
+
end
|
|
1370
|
+
|
|
1371
|
+
with_decisions_lock do
|
|
1372
|
+
heal_proposed!
|
|
1373
|
+
record = decisions.find { |r| r[:id] == id }
|
|
1374
|
+
raise ArgumentError, "Decision #{id} not found." unless record
|
|
1375
|
+
next { id: id, status: new_status, external: [] } if record[:stage] == new_status
|
|
1376
|
+
# A record without a frontmatter block cannot carry a status - lint
|
|
1377
|
+
# already flags it; a transition is a 400, not a NoMethodError (BT-179).
|
|
1378
|
+
raise ArgumentError, "#{id} has no frontmatter block - fix the record before transitioning it." unless record[:frontmatter?]
|
|
1379
|
+
|
|
1380
|
+
unless DECISION_TRANSITIONS[new_status].include?(record[:stage])
|
|
1381
|
+
raise ArgumentError,
|
|
1382
|
+
"#{id} is #{record[:stage]} - #{record[:stage]} → #{new_status} is not a " \
|
|
1383
|
+
'transition. An accepted record changes by amendment or supersession; a ' \
|
|
1384
|
+
'terminal one is history.'
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
# Validate everything before writing anything: a missing or dangling
|
|
1388
|
+
# target must leave both records untouched, never a bare status write
|
|
1389
|
+
# (BT-ADR-0017).
|
|
1390
|
+
target = nil
|
|
1391
|
+
external = []
|
|
1392
|
+
if new_status == 'superseded'
|
|
1393
|
+
ref = superseded_by.to_s.strip
|
|
1394
|
+
raise ArgumentError, 'superseded_by is required: name the decision that supersedes this one.' if ref.empty?
|
|
1395
|
+
|
|
1396
|
+
if ref.start_with?("#{@config.namespace}-ADR-")
|
|
1397
|
+
target = decisions.find { |r| r[:id] == ref }
|
|
1398
|
+
raise ArgumentError, "superseded_by names #{ref}, which does not exist here." unless target
|
|
1399
|
+
else
|
|
1400
|
+
external << ref
|
|
1401
|
+
end
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
# The destination directory may not exist yet (a project scaffolded
|
|
1405
|
+
# before decisions existed, or only proposed/ created on first use) -
|
|
1406
|
+
# make it before any write, so a failed mv can't strand a rewritten
|
|
1407
|
+
# record in its old directory with the new status (BT-179).
|
|
1408
|
+
dest_dir = File.join(@config.decisions_root, new_status)
|
|
1409
|
+
FileUtils.mkdir_p(dest_dir)
|
|
1410
|
+
|
|
1411
|
+
moves_date = %w[accepted rejected].include?(new_status)
|
|
1412
|
+
rewrite_decision(record,
|
|
1413
|
+
status: new_status,
|
|
1414
|
+
date: moves_date ? Date.today.iso8601 : nil,
|
|
1415
|
+
append: new_status == 'superseded' ? ['superseded_by', superseded_by.to_s.strip] : nil)
|
|
1416
|
+
rewrite_decision(target, append: ['supersedes', id]) if target
|
|
1417
|
+
|
|
1418
|
+
FileUtils.mv(record[:path], File.join(dest_dir, record[:filename]))
|
|
1419
|
+
heal_proposed!
|
|
1420
|
+
{ id: id, status: new_status, external: external }
|
|
1421
|
+
end
|
|
1422
|
+
end
|
|
1423
|
+
|
|
1424
|
+
def proposed_order
|
|
1425
|
+
path = @config.proposed_path
|
|
1426
|
+
return [] unless @config.decisions_root && File.exist?(path)
|
|
1427
|
+
|
|
1428
|
+
File.readlines(path, encoding: 'utf-8').filter_map do |l|
|
|
1429
|
+
l[/\A\s*-\s+(#{Regexp.escape(@config.namespace)}-ADR-\d{4})/, 1]
|
|
1430
|
+
end
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
# backlog_reorder's semantics on proposed.md (BT-147): non-entry lines
|
|
1434
|
+
# keep their place, the client's order applies to the entries it knew
|
|
1435
|
+
# about, and entries it did not know about survive at the bottom rather
|
|
1436
|
+
# than being silently deleted.
|
|
1437
|
+
def proposed_reorder(ordered_ids)
|
|
1438
|
+
raise ArgumentError, "'ids' must be an array" unless ordered_ids.is_a?(Array)
|
|
1439
|
+
|
|
1440
|
+
with_decisions_lock do
|
|
1441
|
+
heal_proposed!
|
|
1442
|
+
path = @config.proposed_path
|
|
1443
|
+
next unless File.exist?(path)
|
|
1444
|
+
|
|
1445
|
+
pattern = /\A\s*-\s+(#{Regexp.escape(@config.namespace)}-ADR-\d{4})/
|
|
1446
|
+
lines = File.readlines(path, encoding: 'utf-8')
|
|
1447
|
+
id_to_line = lines.each_with_object({}) { |l, h| (m = l[pattern, 1]) && h[m] = l }
|
|
1448
|
+
non_entry = lines.reject { |l| l[pattern, 1] }
|
|
1449
|
+
ordered = ordered_ids.uniq.filter_map { |id| id_to_line[id] }
|
|
1450
|
+
leftover = lines.select { |l| (m = l[pattern, 1]) && !ordered_ids.include?(m) }
|
|
1451
|
+
atomic_write(path, (non_entry + ordered + leftover).join)
|
|
1452
|
+
end
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1455
|
+
# Surgical frontmatter/prose edit for a transition. Line-level, never a YAML
|
|
1456
|
+
# round-trip - a reserialize would reformat 17 records\' worth of hand-written
|
|
1457
|
+
# frontmatter to make one edit.
|
|
1458
|
+
def rewrite_decision(record, status: nil, date: nil, append: nil)
|
|
1459
|
+
content = File.read(record[:path], encoding: 'utf-8')
|
|
1460
|
+
parts = content.split(/^---\n/, 3)
|
|
1461
|
+
fm, body = parts[1], parts[2]
|
|
1462
|
+
|
|
1463
|
+
# Block-form substitutions throughout: a value is data, never a
|
|
1464
|
+
# backreference pattern (BT-179).
|
|
1465
|
+
fm = fm.sub(/^status:.*$/) { "status: #{status}" } if status
|
|
1466
|
+
if date
|
|
1467
|
+
fm = fm.match?(/^date:/) ? fm.sub(/^date:.*$/) { "date: #{date}" } : fm + "date: #{date}\n"
|
|
1468
|
+
end
|
|
1469
|
+
if append
|
|
1470
|
+
key, value = append
|
|
1471
|
+
fm = if (m = fm.match(/^#{Regexp.escape(key)}:\s*\[(.*)\]\s*$/))
|
|
1472
|
+
existing = m[1].strip
|
|
1473
|
+
list = existing.empty? ? value : "#{existing}, #{value}"
|
|
1474
|
+
fm.sub(/^#{Regexp.escape(key)}:.*$/) { "#{key}: [#{list}]" }
|
|
1475
|
+
else
|
|
1476
|
+
fm + "#{key}: [#{value}]\n"
|
|
1477
|
+
end
|
|
1478
|
+
end
|
|
1479
|
+
# Keep the prose mirror in step so a tool-driven transition never plants a
|
|
1480
|
+
# prose-drift warning; a hand edit remains the lint\'s problem.
|
|
1481
|
+
body = body.sub(/^(##\s+Status\s*\n+)\S+/) { "#{$1}#{status.capitalize}" } if status
|
|
1482
|
+
|
|
1483
|
+
atomic_write(record[:path], "---\n#{fm}---\n#{body}")
|
|
1484
|
+
end
|
|
1485
|
+
|
|
1486
|
+
# CLI adapter for decision transitions - the abort side of the 0005
|
|
1487
|
+
# boundary, exactly as commit/start/done wrap set_stage.
|
|
1488
|
+
def transition_decision(id, new_status, superseded_by: nil)
|
|
1489
|
+
result = set_status(id, new_status, superseded_by: superseded_by)
|
|
1490
|
+
msg = "#{result[:id]} → #{result[:status]}/"
|
|
1491
|
+
unless result[:external].empty?
|
|
1492
|
+
msg += " (#{result[:external].join(', ')} is in another namespace - its supersedes side was NOT written; record it there)"
|
|
1493
|
+
end
|
|
1494
|
+
puts msg
|
|
1495
|
+
rescue ArgumentError => e
|
|
1496
|
+
abort e.message
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
# ── Rake-facing wrappers ──────────────────────────────────────────────────
|
|
1500
|
+
# Thin CLI adapters over the raising primitives: Core itself never aborts;
|
|
1501
|
+
# only these wrappers convert ArgumentError into a clean process exit.
|
|
1502
|
+
|
|
1503
|
+
# Commit to a story: icebox → backlog. The name is deliberate - moving a
|
|
1504
|
+
# story into the backlog is the act of commitment, not the start of work
|
|
1505
|
+
# (that's #start). See docs/flow.md.
|
|
1506
|
+
def commit(id)
|
|
1507
|
+
with_lock do
|
|
1508
|
+
result = find_story(id)
|
|
1509
|
+
abort "Story #{id} not found." unless result
|
|
1510
|
+
abort "#{id} is in #{result[:stage]}, expected 1_icebox." unless result[:stage] == '1_icebox'
|
|
1511
|
+
|
|
1512
|
+
set_stage(id, '2_backlog')
|
|
1513
|
+
puts "Committed #{id} → 2_backlog/#{File.basename(result[:file])}, added to backlog.md"
|
|
1514
|
+
end
|
|
1515
|
+
rescue ArgumentError => e
|
|
1516
|
+
abort e.message
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
# Start work on a story: backlog → started. Pull from the top of the
|
|
1520
|
+
# backlog - the prioritization already happened at commit time.
|
|
1521
|
+
def start(id)
|
|
1522
|
+
with_lock do
|
|
1523
|
+
result = find_story(id)
|
|
1524
|
+
abort "Story #{id} not found." unless result
|
|
1525
|
+
abort "#{id} is in #{result[:stage]}, expected 2_backlog." unless result[:stage] == '2_backlog'
|
|
1526
|
+
|
|
1527
|
+
set_stage(id, '3_started')
|
|
1528
|
+
puts "Started #{id} → 3_started/#{File.basename(result[:file])}"
|
|
1529
|
+
end
|
|
1530
|
+
rescue ArgumentError => e
|
|
1531
|
+
abort e.message
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1534
|
+
def done(id)
|
|
1535
|
+
with_lock do
|
|
1536
|
+
result = find_story(id)
|
|
1537
|
+
abort "Story #{id} not found." unless result
|
|
1538
|
+
abort "#{id} is already done." if result[:stage] == '4_done'
|
|
1539
|
+
|
|
1540
|
+
set_stage(id, '4_done')
|
|
1541
|
+
backlog_remove(id) # set_stage only removes from 2_backlog; drop strays too
|
|
1542
|
+
puts "Done #{id} → 4_done/#{File.basename(result[:file])}"
|
|
1543
|
+
end
|
|
1544
|
+
rescue ArgumentError => e
|
|
1545
|
+
abort e.message
|
|
1546
|
+
end
|
|
1547
|
+
|
|
1548
|
+
# Create a story, optionally setting fields in one step. `fields` is a hash
|
|
1549
|
+
# of #update_story keyword args (size/assignee/blocked_by/linked_to/body/title) - the
|
|
1550
|
+
# same set the `story:edit` task parses. A field-validation failure is
|
|
1551
|
+
# reported against the just-created story so its ID isn't lost.
|
|
1552
|
+
def create(kind, title, fields = {})
|
|
1553
|
+
story = create_story(kind, title)
|
|
1554
|
+
path =
|
|
1555
|
+
if fields.empty?
|
|
1556
|
+
story[:path]
|
|
1557
|
+
else
|
|
1558
|
+
begin
|
|
1559
|
+
update_story(story[:id], **fields)
|
|
1560
|
+
rescue ArgumentError => e
|
|
1561
|
+
abort "Created #{story[:id]} but could not set fields: #{e.message}"
|
|
1562
|
+
end
|
|
1563
|
+
end
|
|
1564
|
+
puts "#{story[:id]}: #{path}"
|
|
1565
|
+
rescue ArgumentError => e
|
|
1566
|
+
abort e.message
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
# Fields #update_story (and the `story:edit` task / `/tracker edit`) can set.
|
|
1570
|
+
EDITABLE_FIELDS = %w[title body size assignee blocked_by linked_to].freeze
|
|
1571
|
+
|
|
1572
|
+
# Editable fields whose value is a comma-separated list of story IDs (rather
|
|
1573
|
+
# than a plain scalar) - the single source for edit_assignments' list-splitting
|
|
1574
|
+
# and locked_update_story's array coercion, so a new relationship field is
|
|
1575
|
+
# added in one place.
|
|
1576
|
+
ID_LIST_FIELDS = %w[blocked_by linked_to].freeze
|
|
1577
|
+
|
|
1578
|
+
# Parse rake-style "field=value" tokens into #update_story keyword args.
|
|
1579
|
+
# Rake comma-splits bracket args, so a comma-separated value like
|
|
1580
|
+
# `blocked_by=A,B` arrives as ["blocked_by=A", "B"]; a token with `=` starts
|
|
1581
|
+
# a field, a bare token continues the previous field's list. An empty value
|
|
1582
|
+
# (`size=`) is kept as "" so the field is removed. Raises on unknown fields.
|
|
1583
|
+
def edit_assignments(tokens)
|
|
1584
|
+
fields = {}
|
|
1585
|
+
current = nil
|
|
1586
|
+
tokens.each do |tok|
|
|
1587
|
+
key = tok.split('=', 2).first.to_s.strip
|
|
1588
|
+
# A token opens a new field only if its key is an editable field name.
|
|
1589
|
+
# A '=' inside a value (e.g. "title=Compare a=1, b=2", which rake splits
|
|
1590
|
+
# on the comma) is thus kept as a continuation, and a bare stray token
|
|
1591
|
+
# ("size" instead of "size=S") raises instead of being silently dropped
|
|
1592
|
+
# (BT-106).
|
|
1593
|
+
if tok.include?('=') && EDITABLE_FIELDS.include?(key)
|
|
1594
|
+
current = key
|
|
1595
|
+
fields[current] = tok.split('=', 2)[1]
|
|
1596
|
+
elsif current
|
|
1597
|
+
fields[current] += ",#{tok}"
|
|
1598
|
+
elsif tok.include?('=')
|
|
1599
|
+
raise ArgumentError, "Unknown field(s): #{key}. Editable: #{EDITABLE_FIELDS.join(', ')}"
|
|
1600
|
+
else
|
|
1601
|
+
raise ArgumentError, "Expected field=value (one of #{EDITABLE_FIELDS.join(', ')}), got: #{tok.strip.inspect}"
|
|
1602
|
+
end
|
|
1603
|
+
end
|
|
1604
|
+
|
|
1605
|
+
fields.each_with_object({}) do |(key, val), kwargs|
|
|
1606
|
+
kwargs[key.to_sym] =
|
|
1607
|
+
ID_LIST_FIELDS.include?(key) ? val.split(',').map(&:strip).reject(&:empty?) : val
|
|
1608
|
+
end
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
def parse_story_file(path, dir_meta)
|
|
1612
|
+
content = read_story(path)
|
|
1613
|
+
ext = File.extname(path)
|
|
1614
|
+
id = File.basename(path)[filename_id_pattern]
|
|
1615
|
+
return nil unless id
|
|
1616
|
+
|
|
1617
|
+
fm, body =
|
|
1618
|
+
# A leading '---' block is YAML frontmatter regardless of extension: some
|
|
1619
|
+
# projects author '.feature' files with YAML frontmatter rather than the
|
|
1620
|
+
# '# key: value' gherkin-comment header, and both must yield the same
|
|
1621
|
+
# fields (BT-120 - otherwise linked_to/size/assignee silently vanish).
|
|
1622
|
+
if content.start_with?("---\n")
|
|
1623
|
+
parts = content.split(/^---\n/, 3)
|
|
1624
|
+
# One bad frontmatter block must not take down every caller of
|
|
1625
|
+
# all_stories - treat it as empty and keep the story visible.
|
|
1626
|
+
fm_hash = begin
|
|
1627
|
+
YAML.safe_load(parts[1].to_s, permitted_classes: [Date, Time])
|
|
1628
|
+
rescue Psych::Exception => e
|
|
1629
|
+
warn "[BaconTracker] ignoring malformed frontmatter in #{File.basename(path)}: #{e.message}"
|
|
1630
|
+
nil
|
|
1631
|
+
end
|
|
1632
|
+
fm_hash = {} unless fm_hash.is_a?(Hash)
|
|
1633
|
+
[fm_hash.transform_keys(&:to_s), parts[2].to_s.lstrip]
|
|
1634
|
+
elsif ext == '.feature'
|
|
1635
|
+
lines = content.lines.take_while { |l| l.match?(FEATURE_HEADER) }
|
|
1636
|
+
# A header line without a ': ' separator (e.g. '# type:bug') splits to
|
|
1637
|
+
# a single element - tolerate it instead of letting .to_h raise and
|
|
1638
|
+
# 500 every caller of all_stories, matching the YAML branch's
|
|
1639
|
+
# resilience above (BT-078; the YAML side was hardened in BT-055).
|
|
1640
|
+
fm_hash = lines.each_with_object({}) do |l, h|
|
|
1641
|
+
key, val = l.sub(/^# /, '').chomp.split(': ', 2)
|
|
1642
|
+
h[key] = val unless val.nil?
|
|
1643
|
+
end
|
|
1644
|
+
rest = content.lines.drop(lines.size).join.lstrip
|
|
1645
|
+
[fm_hash, rest]
|
|
1646
|
+
else
|
|
1647
|
+
[{}, content]
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
raw_size = fm['size']&.to_s&.upcase
|
|
1651
|
+
size = %w[S M L].include?(raw_size) ? raw_size : nil
|
|
1652
|
+
|
|
1653
|
+
blocked_by = decision_refs(fm['blocked_by'])
|
|
1654
|
+
linked_to = decision_refs(fm['linked_to'])
|
|
1655
|
+
|
|
1656
|
+
assignee = fm['assignee']&.to_s&.strip
|
|
1657
|
+
assignee = nil if assignee&.empty?
|
|
1658
|
+
|
|
1659
|
+
st_lines = subtask_line_indices(body.lines, ext)
|
|
1660
|
+
subtasks = subtask_counts(body, ext)
|
|
1661
|
+
|
|
1662
|
+
{ id: id, type: dir_meta[:type], stage: dir_meta[:stage], dir: dir_meta[:dir],
|
|
1663
|
+
path: path, title: humanize_slug(path), body: body, size: size,
|
|
1664
|
+
blocked_by: blocked_by, linked_to: linked_to, assignee: assignee, subtasks: subtasks,
|
|
1665
|
+
# Body-line addresses of the subtasks - the client renders/toggles by
|
|
1666
|
+
# these instead of re-deriving fence rules (the BT-049 drift class).
|
|
1667
|
+
subtask_lines: st_lines.empty? ? nil : st_lines,
|
|
1668
|
+
declared_status: fm['status']&.to_s }
|
|
1669
|
+
end
|
|
1670
|
+
|
|
1671
|
+
# Indices of body lines that are subtasks, skipping fenced content
|
|
1672
|
+
# (``` code blocks in markdown, """ docstrings in gherkin) to stay
|
|
1673
|
+
# consistent with what the board renders as clickable.
|
|
1674
|
+
def subtask_line_indices(lines, ext)
|
|
1675
|
+
fence = ext == '.feature' ? '"""' : '```'
|
|
1676
|
+
in_fence = false
|
|
1677
|
+
lines.each_index.select do |i|
|
|
1678
|
+
if lines[i].lstrip.start_with?(fence)
|
|
1679
|
+
in_fence = !in_fence
|
|
1680
|
+
next false
|
|
1681
|
+
end
|
|
1682
|
+
!in_fence && lines[i].match?(SUBTASK)
|
|
1683
|
+
end
|
|
1684
|
+
end
|
|
1685
|
+
|
|
1686
|
+
def subtask_counts(body, ext)
|
|
1687
|
+
lines = body.to_s.lines
|
|
1688
|
+
states = subtask_line_indices(lines, ext).map { |i| lines[i][SUBTASK, 2] }
|
|
1689
|
+
return nil if states.empty?
|
|
1690
|
+
|
|
1691
|
+
{ done: states.count { |s| s.casecmp?('x') }, total: states.size }
|
|
1692
|
+
end
|
|
1693
|
+
|
|
1694
|
+
def toggle_subtask(id, index, done)
|
|
1695
|
+
raise ArgumentError, "'index' must be a non-negative integer" unless index.is_a?(Integer) && index >= 0
|
|
1696
|
+
raise ArgumentError, "'done' must be true or false" unless [true, false].include?(done)
|
|
1697
|
+
|
|
1698
|
+
with_lock do
|
|
1699
|
+
result = find_story(id)
|
|
1700
|
+
raise ArgumentError, "Story #{id} not found." unless result
|
|
1701
|
+
|
|
1702
|
+
ext = File.extname(result[:file])
|
|
1703
|
+
# find_story's glob ("<id>-*") is looser than parse_story_file's id
|
|
1704
|
+
# contract, so a hand-created odd filename can match yet parse to nil -
|
|
1705
|
+
# raise ArgumentError (→ 400) instead of a NoMethodError → 500 (BT-080).
|
|
1706
|
+
parsed = parse_story_file(result[:file], result)
|
|
1707
|
+
raise ArgumentError, "Story #{id} not found." unless parsed
|
|
1708
|
+
|
|
1709
|
+
lines = parsed[:body].lines
|
|
1710
|
+
pos = subtask_line_indices(lines, ext)[index]
|
|
1711
|
+
raise ArgumentError, "Story #{id} has no subtask at index #{index}." unless pos
|
|
1712
|
+
|
|
1713
|
+
lines[pos] = lines[pos].sub(SUBTASK) { "#{$1}#{done ? 'x' : ' '}#{$3}" }
|
|
1714
|
+
update_story(id, body: lines.join)
|
|
1715
|
+
subtask_counts(lines.join, ext)
|
|
1716
|
+
end
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
def all_stories
|
|
1720
|
+
story_dirs.flat_map do |d|
|
|
1721
|
+
safe_glob(d[:path], '*.{md,feature}').filter_map do |f|
|
|
1722
|
+
next if File.basename(f).start_with?('_')
|
|
1723
|
+
|
|
1724
|
+
parse_story_file(f, d)
|
|
1725
|
+
end
|
|
1726
|
+
end
|
|
1727
|
+
end
|
|
1728
|
+
|
|
1729
|
+
# Annotate each story with its derived reverse relationships: `blocks` (the
|
|
1730
|
+
# inverse of `blocked_by`) and `linked_from` (the inverse of `linked_to`).
|
|
1731
|
+
# A file-based tracker can't transactionally write the far side of a pair, so
|
|
1732
|
+
# a relationship lives on exactly one story's frontmatter and the other end is
|
|
1733
|
+
# computed here over the whole set - the board renders both ends without any
|
|
1734
|
+
# second-file write. `linked_to`/`linked_from` describe the same symmetric
|
|
1735
|
+
# link from each side; the UI unions them into one 🔗 badge.
|
|
1736
|
+
def with_reverse_links(stories)
|
|
1737
|
+
blocks = Hash.new { |h, k| h[k] = [] }
|
|
1738
|
+
linked_from = Hash.new { |h, k| h[k] = [] }
|
|
1739
|
+
stories.each do |s|
|
|
1740
|
+
s[:blocked_by].each { |bid| blocks[bid] << s[:id] }
|
|
1741
|
+
s[:linked_to].each { |lid| linked_from[lid] << s[:id] }
|
|
1742
|
+
end
|
|
1743
|
+
stories.map { |s| s.merge(blocks: blocks[s[:id]], linked_from: linked_from[s[:id]]) }
|
|
1744
|
+
end
|
|
1745
|
+
|
|
1746
|
+
def stats
|
|
1747
|
+
# Counting is glob-only - the menu app polls this every few seconds and
|
|
1748
|
+
# stage lives in the directory name, so no file needs to be read/parsed.
|
|
1749
|
+
esc_ns = escape_glob(@config.namespace)
|
|
1750
|
+
counts = Hash.new(0)
|
|
1751
|
+
story_dirs.each { |d| counts[d[:stage]] += safe_glob(d[:path], "#{esc_ns}-[0-9]*.{md,feature}").size }
|
|
1752
|
+
|
|
1753
|
+
done_n = counts['4_done']
|
|
1754
|
+
started_n = counts['3_started']
|
|
1755
|
+
backlog_n = counts['2_backlog']
|
|
1756
|
+
icebox_n = counts['1_icebox']
|
|
1757
|
+
total = done_n + started_n + backlog_n + icebox_n
|
|
1758
|
+
pct = total.zero? ? 0 : (done_n * 100.0 / total).round
|
|
1759
|
+
|
|
1760
|
+
next_line = backlog_lines.first&.strip
|
|
1761
|
+
next_task = next_line&.sub(/^-\s+#{id_pattern}\s+/, '')
|
|
1762
|
+
|
|
1763
|
+
{ done: done_n, started: started_n, backlog: backlog_n, icebox: icebox_n,
|
|
1764
|
+
total: total, progress_pct: pct, next_task: next_task }
|
|
1765
|
+
end
|
|
1766
|
+
|
|
1767
|
+
def duplicate_id_stages
|
|
1768
|
+
id_to_paths = Hash.new { |h, k| h[k] = [] }
|
|
1769
|
+
story_dirs.each do |d|
|
|
1770
|
+
safe_glob(d[:path], '*.{md,feature}').each do |f|
|
|
1771
|
+
next if File.basename(f).start_with?('_')
|
|
1772
|
+
|
|
1773
|
+
id = File.basename(f)[filename_id_pattern]
|
|
1774
|
+
id_to_paths[id] << f if id
|
|
1775
|
+
end
|
|
1776
|
+
end
|
|
1777
|
+
id_to_paths.select { |_, paths| paths.size > 1 }
|
|
1778
|
+
end
|
|
1779
|
+
|
|
1780
|
+
def max_story_id
|
|
1781
|
+
story_dirs.flat_map do |d|
|
|
1782
|
+
safe_glob(d[:path], '*.{md,feature}').filter_map do |f|
|
|
1783
|
+
File.basename(f)[/\A#{Regexp.escape(@config.namespace)}-(\d+)/, 1]&.to_i
|
|
1784
|
+
end
|
|
1785
|
+
end.max || 0
|
|
1786
|
+
end
|
|
1787
|
+
|
|
1788
|
+
def set_stage(id, new_stage)
|
|
1789
|
+
raise ArgumentError, "Invalid stage: #{new_stage}" unless STAGES.include?(new_stage)
|
|
1790
|
+
|
|
1791
|
+
with_lock do
|
|
1792
|
+
heal_backlog!
|
|
1793
|
+
result = find_story(id)
|
|
1794
|
+
raise ArgumentError, "Story #{id} not found." unless result
|
|
1795
|
+
next STATUS_MAP[result[:stage]] if result[:stage] == new_stage
|
|
1796
|
+
# 4_done is append-only (docs/flow.md): a done story is never reopened -
|
|
1797
|
+
# file a new story instead. Guards the board's drag-out-of-done (BT-102).
|
|
1798
|
+
if result[:stage] == '4_done'
|
|
1799
|
+
raise ArgumentError, "#{id} is done - done is append-only; file a new story instead of reopening it."
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
old_stage = result[:stage]
|
|
1803
|
+
old_path = result[:file]
|
|
1804
|
+
# Validate before moving: the status rewrite raises on malformed
|
|
1805
|
+
# frontmatter, and doing it after the mv left the file in the new stage
|
|
1806
|
+
# with a 400 reported and backlog.md untouched (BT-179).
|
|
1807
|
+
content = set_field_in(read_story(old_path), File.extname(old_path), 'status',
|
|
1808
|
+
STATUS_MAP[new_stage], File.basename(old_path))
|
|
1809
|
+
new_path = move_to_stage(result, new_stage)
|
|
1810
|
+
atomic_write(new_path, content)
|
|
1811
|
+
|
|
1812
|
+
backlog_remove(id) if old_stage == '2_backlog'
|
|
1813
|
+
backlog_add(id, new_path) if new_stage == '2_backlog'
|
|
1814
|
+
|
|
1815
|
+
STATUS_MAP[new_stage]
|
|
1816
|
+
end
|
|
1817
|
+
end
|
|
1818
|
+
|
|
1819
|
+
def backlog_reorder(ordered_ids)
|
|
1820
|
+
raise ArgumentError, "'ids' must be an array" unless ordered_ids.is_a?(Array)
|
|
1821
|
+
|
|
1822
|
+
with_lock do
|
|
1823
|
+
heal_backlog!
|
|
1824
|
+
next unless File.exist?(@config.backlog_path)
|
|
1825
|
+
|
|
1826
|
+
lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
|
|
1827
|
+
id_to_line = lines.each_with_object({}) do |l, h|
|
|
1828
|
+
(m = backlog_line_id(l)) && h[m] = l
|
|
1829
|
+
end
|
|
1830
|
+
non_story = lines.reject { |l| backlog_line_id(l) }
|
|
1831
|
+
ordered = ordered_ids.uniq.filter_map { |id| id_to_line[id] }
|
|
1832
|
+
# A stale client's order may not know about lines added since it
|
|
1833
|
+
# loaded - keep them (at the bottom) instead of silently deleting.
|
|
1834
|
+
leftover = lines.select { |l| (m = backlog_line_id(l)) && !ordered_ids.include?(m) }
|
|
1835
|
+
atomic_write(@config.backlog_path, (non_story + ordered + leftover).join)
|
|
1836
|
+
end
|
|
1837
|
+
end
|
|
1838
|
+
|
|
1839
|
+
def create_story(kind, title, stage: '1_icebox', size: nil)
|
|
1840
|
+
assert_safe_title!(title)
|
|
1841
|
+
# Validate size the same way update_story does, before an ID is consumed -
|
|
1842
|
+
# create used to silently drop an invalid size while update raised (BT-101).
|
|
1843
|
+
norm_size = normalized_size!(size)
|
|
1844
|
+
meta = STORY_DIRS.find { |_, m| m[:type] == kind }
|
|
1845
|
+
raise ArgumentError, "Unknown kind: #{kind}" unless meta
|
|
1846
|
+
raise ArgumentError, "Unknown stage: #{stage}" unless STAGES.include?(stage)
|
|
1847
|
+
|
|
1848
|
+
dir, m = meta
|
|
1849
|
+
ext = m[:ext]
|
|
1850
|
+
|
|
1851
|
+
with_lock do
|
|
1852
|
+
id_num = consume_id
|
|
1853
|
+
id = format_id(id_num)
|
|
1854
|
+
slug = filename_slug(title)
|
|
1855
|
+
|
|
1856
|
+
filename = "#{id}-#{slug}#{ext}"
|
|
1857
|
+
dest_dir = File.join(@config.tracker_root, dir, stage)
|
|
1858
|
+
FileUtils.mkdir_p(dest_dir)
|
|
1859
|
+
dest = File.join(dest_dir, filename)
|
|
1860
|
+
status = STATUS_MAP[stage]
|
|
1861
|
+
|
|
1862
|
+
template_path = File.join(@config.tracker_root, dir, "_template#{ext}")
|
|
1863
|
+
template = if File.exist?(template_path)
|
|
1864
|
+
File.read(template_path, encoding: 'utf-8')
|
|
1865
|
+
else
|
|
1866
|
+
Templates.for(ext, kind)
|
|
1867
|
+
end
|
|
1868
|
+
filled = if ext == '.feature'
|
|
1869
|
+
template.sub('Name the Feature') { title }
|
|
1870
|
+
else
|
|
1871
|
+
template.sub(/\ATitle:.*/) { "Title: #{title}" }
|
|
1872
|
+
end
|
|
1873
|
+
fm = kind == 'feature' ? frontmatter_feature(id, kind, status) : frontmatter_md(id, kind, status)
|
|
1874
|
+
content = fm + filled
|
|
1875
|
+
|
|
1876
|
+
atomic_write(dest, content)
|
|
1877
|
+
backlog_add(id, dest) if stage == '2_backlog'
|
|
1878
|
+
set_frontmatter_field(dest, 'size', norm_size) if norm_size
|
|
1879
|
+
|
|
1880
|
+
parse_story_file(dest, { type: kind, stage: stage, dir: dir })
|
|
1881
|
+
end
|
|
1882
|
+
end
|
|
1883
|
+
|
|
1884
|
+
def delete_story(id)
|
|
1885
|
+
with_lock do
|
|
1886
|
+
result = find_story(id)
|
|
1887
|
+
raise ArgumentError, "Story #{id} not found." unless result
|
|
1888
|
+
# Done is a permanent record (docs/flow.md / README) - refuse to delete
|
|
1889
|
+
# it from any front-end, not just the /tracker prose path (BT-081).
|
|
1890
|
+
if result[:stage] == '4_done'
|
|
1891
|
+
raise ArgumentError, "#{id} is done - done stories are a permanent record and are not deleted."
|
|
1892
|
+
end
|
|
1893
|
+
|
|
1894
|
+
backlog_remove(id) if result[:stage] == '2_backlog'
|
|
1895
|
+
File.delete(result[:file])
|
|
1896
|
+
true
|
|
1897
|
+
end
|
|
1898
|
+
end
|
|
1899
|
+
|
|
1900
|
+
def update_story(id, title: nil, body: nil, size: nil, blocked_by: nil, linked_to: nil, assignee: nil)
|
|
1901
|
+
assert_safe_title!(title) if title
|
|
1902
|
+
with_lock { locked_update_story(id, title, body, size, blocked_by, linked_to, assignee) }
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1905
|
+
# The body of update_story, run under the write lock.
|
|
1906
|
+
def locked_update_story(id, title, body, size, blocked_by, linked_to, assignee)
|
|
1907
|
+
result = find_story(id)
|
|
1908
|
+
raise ArgumentError, "Story #{id} not found." unless result
|
|
1909
|
+
|
|
1910
|
+
path = result[:file]
|
|
1911
|
+
ext = File.extname(path)
|
|
1912
|
+
content = read_story(path)
|
|
1913
|
+
|
|
1914
|
+
if [size, blocked_by, linked_to, assignee].any? { |v| !v.nil? } &&
|
|
1915
|
+
ext != '.feature' && !content.start_with?("---\n")
|
|
1916
|
+
raise ArgumentError, "#{File.basename(path)} has no recognizable frontmatter"
|
|
1917
|
+
end
|
|
1918
|
+
|
|
1919
|
+
if body
|
|
1920
|
+
content =
|
|
1921
|
+
# A leading '---' block is YAML frontmatter regardless of extension and
|
|
1922
|
+
# must be preserved as such, even on a '.feature' authored that way
|
|
1923
|
+
# (BT-120) - mirrors parse_story_file / set_field_in precedence.
|
|
1924
|
+
if content.start_with?("---\n")
|
|
1925
|
+
parts = content.split(/^---\n/, 3)
|
|
1926
|
+
# Same guard set_field_in has: refuse a body edit on a file with an
|
|
1927
|
+
# opening '---' but no closing one, instead of fusing the body into
|
|
1928
|
+
# the frontmatter block (BT-100).
|
|
1929
|
+
raise ArgumentError, "#{File.basename(path)} has malformed frontmatter" unless parts.size == 3
|
|
1930
|
+
|
|
1931
|
+
"---\n#{parts[1]}---\n\n#{body.chomp}\n"
|
|
1932
|
+
elsif ext == '.feature'
|
|
1933
|
+
fm_lines = content.lines.take_while { |l| l.match?(FEATURE_HEADER) }
|
|
1934
|
+
fm_lines.join + "\n" + body.chomp + "\n"
|
|
1935
|
+
else
|
|
1936
|
+
body.chomp + "\n"
|
|
1937
|
+
end
|
|
1938
|
+
end
|
|
1939
|
+
|
|
1940
|
+
# Title substitution runs after the body rebuild - a combined edit's body
|
|
1941
|
+
# still carries the old title line, which would otherwise win.
|
|
1942
|
+
if title
|
|
1943
|
+
content = if ext == '.feature'
|
|
1944
|
+
content.sub(/^Feature: .+/) { "Feature: #{title}" }
|
|
1945
|
+
else
|
|
1946
|
+
content.sub(/^Title: .+/) { "Title: #{title}" }
|
|
1947
|
+
end
|
|
1948
|
+
end
|
|
1949
|
+
|
|
1950
|
+
# Frontmatter edits fold into the in-memory content - every validation
|
|
1951
|
+
# runs before the single write, so a failure leaves the file untouched.
|
|
1952
|
+
label = File.basename(path)
|
|
1953
|
+
|
|
1954
|
+
unless size.nil?
|
|
1955
|
+
content = set_field_in(content, ext, 'size', normalized_size!(size), label)
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
unless blocked_by.nil?
|
|
1959
|
+
raise ArgumentError, 'blocked_by must be an array' unless blocked_by.is_a?(Array)
|
|
1960
|
+
|
|
1961
|
+
content = set_field_in(content, ext, 'blocked_by', blocked_by.empty? ? nil : blocked_by.join(', '), label)
|
|
1962
|
+
end
|
|
1963
|
+
|
|
1964
|
+
unless linked_to.nil?
|
|
1965
|
+
raise ArgumentError, 'linked_to must be an array' unless linked_to.is_a?(Array)
|
|
1966
|
+
|
|
1967
|
+
content = set_field_in(content, ext, 'linked_to', linked_to.empty? ? nil : linked_to.join(', '), label)
|
|
1968
|
+
end
|
|
1969
|
+
|
|
1970
|
+
unless assignee.nil?
|
|
1971
|
+
raise ArgumentError, 'assignee must be a string' unless assignee.is_a?(String)
|
|
1972
|
+
|
|
1973
|
+
content = set_field_in(content, ext, 'assignee', assignee.empty? ? nil : assignee, label)
|
|
1974
|
+
end
|
|
1975
|
+
|
|
1976
|
+
if title || body || !size.nil? || !blocked_by.nil? || !linked_to.nil? || !assignee.nil?
|
|
1977
|
+
atomic_write(path, content)
|
|
1978
|
+
new_path = title ? File.join(File.dirname(path), "#{id}-#{filename_slug(title)}#{ext}") : path
|
|
1979
|
+
if path != new_path
|
|
1980
|
+
FileUtils.mv(path, new_path)
|
|
1981
|
+
path = new_path
|
|
1982
|
+
if result[:stage] == '2_backlog' && File.exist?(@config.backlog_path)
|
|
1983
|
+
lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
|
|
1984
|
+
atomic_write(@config.backlog_path,
|
|
1985
|
+
lines.map { |l| backlog_line_id(l) == id ? "- #{id} #{title}\n" : l }.join)
|
|
1986
|
+
end
|
|
1987
|
+
end
|
|
1988
|
+
end
|
|
1989
|
+
|
|
1990
|
+
path
|
|
1991
|
+
end
|
|
1992
|
+
end
|
|
1993
|
+
end
|