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.
@@ -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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')}</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