bacon-tracker 1.1.0 → 1.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3745eb09dd23fb800b9cccac6561f493e68e732fc03a3a58c4c2b5e30ccc424c
4
- data.tar.gz: c8d4a8b7b14f29d9217e28102480d5304e90d7199a1127c577837ff253306150
3
+ metadata.gz: 322d2ee34076ea624f44901ff5f2a67ced37ddc3e0d0677ead1cf40c39fc4a94
4
+ data.tar.gz: 1501ba1a4b4aea4e4cd7d6c7e3e8e8bb565e4b99d18624765e06e374e2982dc2
5
5
  SHA512:
6
- metadata.gz: 4a534b8159e6d1498bf9a1cceb51198714bbdc426448d1df087e9b2b51727e723d6874eba65cd9f2175c7e7b513a01ebb5b4144edcddcc08b68e626c076da0f3
7
- data.tar.gz: a2f9f2594a12b8fbaaf58f25052553fbf9d84075dcd90697960656204f7580918fa717edd96df8fc31fba532ecd9e568dd39a3a847c0b5f27101a70f576349cf
6
+ metadata.gz: 6a3ea03b467fb292aec46df928970edf1949b21aa6f8cf1a5d8c76b6426ebc931bec202a07a02985c83620d369aea37e45a21eff7b0f373d80d0a9b0b3bea899
7
+ data.tar.gz: ead2444ec10dd2fd5d16f22e1c243b9f98de2b0b614db513b08d5e7b92cf92e5403b782bbc55841f999b0c9c42f89ba1a0c7f8359c58d7f8d481f37850b201ae
data/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), [Semantic Versioning](https://semver.org/).
5
5
 
6
+
7
+ ## [1.1.2] - 2026-10-23
8
+
9
+ ### Fixed
10
+
11
+ - Docs pages and decision records no longer all show "could not load" after the first page with a fenced code block. kramdown required Rouge to highlight the block whenever Rouge was installed, and Rouge loaded a second copy of `strscan`, which broke every markdown render after it until the server restarted. Code blocks are now rendered plain, without a syntax highlighter, which is how the page already showed them.
12
+
13
+ ## [1.1.1] - 2026-10-21
14
+
15
+ ### Fixed
16
+
17
+ - `rake story:commit` (and every other path that adds a line to `backlog.md`) writes the story's own title to the line, the same one the board shows, instead of the humanized filename slug. The slug is the fallback only when the file has neither a title line nor an opening heading. Retitling a committed story rewrites its line even when the filename does not change. `stats.next_task` follows.
18
+ - `rake story:lint` reports a `backlog.md` line whose title is not the story's (`title-drift`), on the line in `backlog.md`, and fails on it - the same comparison the shared CI lint makes, so the local check and CI agree on the same tree.
19
+ - A story created from a `_template.md` or `_template.feature` that starts with a header block of its own (`id`, `type`, `status` with a placeholder id) now has exactly one header and the title as typed. It used to carry both headers, the template's placeholder id and the template's placeholder title; the typed title reached only the filename. The title is filled into the template's first `Title:`/`Feature:` line wherever it sits, and a template without one gets the line added. `rake story:lint` points out such a template (`template-header`) as a notice, so the project can drop the block.
20
+ - `rake story:feature|bug|chore` with an unknown `size=` (say `XS`) is refused before a story is created, like the API and the board already were. It used to create the story, spend its number, and only then report "could not set fields", leaving an unsized story behind. One list of sizes (`S`, `M`, `L`) now backs creating, editing, showing and linting.
21
+ - `rake story:lint` reports a story whose file carries a size the tracker does not know (`unknown-size`), naming the story and its `size:` line. It is a notice, not a failure: the board shows such a story without a size, and old files are not made to be swept. With `LINT_FORMAT=github` it is a `::notice` annotation.
22
+ - A bug or chore with no `Title:` line that opens with a markdown heading now takes the heading as its title on the board, the dashboard and the API, the way decision records do. It used to fall back to the humanized filename slug, which loses dashes, punctuation, capitals and umlauts ("# Fix the Ä-key — today" showed as "fix the ae key today"). Retitling such a story rewrites the heading. The slug remains the fallback when there is neither.
23
+
6
24
  ## [1.1.0] - 2026-10-01
7
25
 
8
26
  ### Changed
@@ -107,7 +107,7 @@ type must be `feature`, `bug`, or `chore`. The title is everything after the typ
107
107
  3. Pad N to 3 digits: `NNN`
108
108
  4. Slugify the title: spell out `ä`/`ö`/`ü`/`ß` as `ae`/`oe`/`ue`/`ss`, drop accents from other letters (`é` → `e`), lowercase it, turn each run of characters that aren't `a-z` or `0-9` into a single hyphen, and trim hyphens from both ends. If nothing is left, the slug is `non-latin-REPLACE-ME`
109
109
  5. Extension: `.feature` for features, `.md` for bugs and chores
110
- 6. Create `$TRACKER_ROOT/<plural-type>/1_icebox/<NS>-<NNN>-<slug>.<ext>` (e.g. `features/1_icebox/...`). The body is the type's `_template.feature` / `_template.md` if one exists, with the title filled in (`Feature: <title>`, or `Title: <title>` on the first line for `.md`). Write frontmatter **in the file's format**: for `.md`, a YAML block `---` / `id: <NS>-<NNN>` / `type: <type>` / `status: icebox` / `---`; for `.feature`, comment-style header lines `# id: <NS>-<NNN>` / `# type: <type>` / `# status: icebox`. (A `.feature` file is parsed by its `# key:` lines **or** a leading YAML `---` block if one is present - but a file mixes only one style, so match the sibling files in the same tracker rather than introducing the other.)
110
+ 6. Create `$TRACKER_ROOT/<plural-type>/1_icebox/<NS>-<NNN>-<slug>.<ext>` (e.g. `features/1_icebox/...`). The body is the type's `_template.feature` / `_template.md` if one exists, with the title filled into its first `Feature:` / `Title:` line (add the line at the top if the template has none). If the template itself starts with a header block (`---` … `---`, or `# id:` lines), leave that block out - the new story gets exactly one header, written next. Write frontmatter **in the file's format**: for `.md`, a YAML block `---` / `id: <NS>-<NNN>` / `type: <type>` / `status: icebox` / `---`; for `.feature`, comment-style header lines `# id: <NS>-<NNN>` / `# type: <type>` / `# status: icebox`. (A `.feature` file is parsed by its `# key:` lines **or** a leading YAML `---` block if one is present - but a file mixes only one style, so match the sibling files in the same tracker rather than introducing the other.)
111
111
  7. If any `field=value` tokens were given, set them in the frontmatter (same rules as `edit`)
112
112
  8. Confirm the new ID and file path
113
113
 
@@ -122,7 +122,7 @@ Move a story from icebox → backlog (committing to do it).
122
122
  1. Find the file: `find $TRACKER_ROOT -path "*/1_icebox/${ID}-*"`
123
123
  2. `mv` it to the corresponding `2_backlog/` directory
124
124
  3. Update `status:` in the frontmatter to `backlog`
125
- 4. Append `- <ID> <title>` to `backlog.md`, where the title is the filename slug with hyphens as spaces (bottom = lowest priority, unless user specifies a position)
125
+ 4. Append `- <ID> <title>` to `backlog.md`, where the title is the story's own: its `Title:`/`Feature:` line, or the opening `#` heading of a `.md` without one, or the filename slug with hyphens as spaces when there is neither (bottom = lowest priority, unless user specifies a position)
126
126
  5. Confirm new stage
127
127
 
128
128
  ---
@@ -158,7 +158,7 @@ Update story fields in place. Fields: `title`, `size` (S/M/L), `assignee` (initi
158
158
 
159
159
  1. Find the file: `find $TRACKER_ROOT -name "${ID}-*"`
160
160
  2. Frontmatter fields: update the existing line, or add it if missing - matching the file's format (comment-style for `.feature`, YAML for `.md`). An empty value (`size=`) removes the line.
161
- 3. `title`: re-slugify the new title, `mv` the file to `<ID>-<new-slug>.<ext>` in its current stage directory, and update the story's `backlog.md` line if it has one (keep the `- <ID> <title>` format)
161
+ 3. `title`: rewrite the `Title:`/`Feature:` line (or the opening `#` heading of a `.md` that has no `Title:` line), re-slugify the new title, `mv` the file to `<ID>-<new-slug>.<ext>` in its current stage directory, and update the story's `backlog.md` line if it has one (keep the `- <ID> <title>` format)
162
162
  4. Reject values containing newlines - the gem treats these as invalid everywhere
163
163
  5. Confirm what changed
164
164
 
@@ -183,7 +183,7 @@ Display a story's content:
183
183
 
184
184
  1. `find $TRACKER_ROOT -name "${ID}-*"` to locate the file
185
185
  2. Read it, strip the frontmatter block
186
- 3. Print: ID, type, stage, title (the `Title:`/`Feature:` line; humanised from the filename if that line is missing), any set metadata fields, then body
186
+ 3. Print: ID, type, stage, title (the `Title:`/`Feature:` line; for a `.md` without one, its opening `#` heading; humanised from the filename if there is neither), any set metadata fields, then body
187
187
 
188
188
  ```
189
189
  APP-003 - implement user auth
@@ -288,12 +288,13 @@ module BaconTracker
288
288
  puts lines.first.strip
289
289
  end
290
290
 
291
- desc 'Check backlog.md against 2_backlog story files for drift, flag duplicate IDs across stages, check blocked_by/linked_to, and verify .next-id'
291
+ desc 'Check backlog.md against 2_backlog story files for drift (membership and titles), flag duplicate IDs across stages, check blocked_by/linked_to, sizes, templates and .next-id'
292
292
  task :lint do
293
293
  listed = core.backlog_ids
294
294
  on_disk = core.backlog_story_files.filter_map { |f| File.basename(f)[core.filename_id_pattern] }
295
295
  phantom = listed - on_disk
296
296
  unlisted = on_disk - listed
297
+ titles = core.backlog_title_drift
297
298
  dupes = core.duplicate_id_stages
298
299
  drift = core.status_drift
299
300
  max_id = core.max_story_id
@@ -302,13 +303,15 @@ module BaconTracker
302
303
 
303
304
  rel = core.relationship_findings
304
305
  rel_warn, rel_err = rel.partition { |f| BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) }
306
+ sizes = core.unknown_sizes
307
+ templates = core.template_headers
305
308
 
306
309
  # Flow signals (rel_warn) are reported but never fail the build: a
307
310
  # started story waiting on a blocker is a legitimate state per
308
311
  # docs/flow.md, so failing CI on it would train people to ignore lint.
309
- broken = !(phantom.empty? && unlisted.empty? && dupes.empty? && drift.empty? && rel_err.empty?) || stale_id
312
+ broken = !(phantom.empty? && unlisted.empty? && titles.empty? && dupes.empty? && drift.empty? && rel_err.empty?) || stale_id
310
313
 
311
- if !broken && rel_warn.empty?
314
+ if !broken && rel_warn.empty? && sizes.empty? && templates.empty?
312
315
  puts "Backlog is clean - #{listed.size} stories."
313
316
  else
314
317
  # One list, two renderings. Each entry carries the text (identical
@@ -332,6 +335,12 @@ module BaconTracker
332
335
  add.call("unlisted: #{id} has file in 2_backlog/ but missing from backlog.md",
333
336
  core.backlog_story_files.find { |f| File.basename(f).start_with?(id) } || backlog)
334
337
  end
338
+ # The estate's CI lint fails on this; reporting it here keeps the
339
+ # local check and CI in agreement on the same tree (BT-186).
340
+ titles.each do |t|
341
+ add.call("title-drift: #{t[:id]} is listed in backlog.md as '#{t[:listed]}' but the story is titled '#{t[:title]}'",
342
+ backlog, t[:line])
343
+ end
335
344
  dupes.each do |id, paths|
336
345
  text = "duplicate: #{id} found in #{paths.map { |p| p.sub("#{core.config.tracker_root}/", '') }.join(' and ')}"
337
346
  paths.each { |dup| add.call(text, dup) }
@@ -351,6 +360,20 @@ module BaconTracker
351
360
  BaconTracker::Tasks.line_matching(f[:path], /^#?\s*#{field}:/),
352
361
  BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) ? :notice : :error)
353
362
  end
363
+ # A size the tracker does not know is reported, not failed (BT-184):
364
+ # the board shows the story without one, and a project is not made
365
+ # to sweep its old stories to get a green build.
366
+ sizes.each do |f|
367
+ add.call("unknown-size: #{f[:id]} says size '#{f[:size]}' - sizes are #{BaconTracker::Core::SIZES.join(', ')}, the board shows none",
368
+ f[:path], BaconTracker::Tasks.line_matching(f[:path], /^#?\s*size:/), :notice)
369
+ end
370
+ # A template that starts with its own header (BT-185): the rake
371
+ # task and the API drop it, but a hand copy would not. Reported,
372
+ # not failed - nothing on the board is wrong.
373
+ templates.each do |f|
374
+ add.call("template-header: #{File.basename(f[:path])} in #{f[:dir]}/ starts with a header block - new stories get their own, take it out of the template",
375
+ f[:path], 1, :notice)
376
+ end
354
377
 
355
378
  if ENV['LINT_FORMAT'] == 'github'
356
379
  # Duplicates share one text across two files; each still gets its
@@ -360,7 +383,7 @@ module BaconTracker
360
383
  else
361
384
  findings.uniq { |f| f[:text] }.each { |f| warn " #{f[:text]}" }
362
385
  end
363
- puts 'No integrity problems - the note above is a flow signal, not a failure.' if !broken && rel_warn.any?
386
+ puts 'No integrity problems - the note above does not fail the check.' if !broken && (rel_warn.any? || sizes.any? || templates.any?)
364
387
  exit 1 if broken
365
388
  end
366
389
  end
@@ -1,3 +1,3 @@
1
1
  module BaconTracker
2
- VERSION = "1.1.0"
2
+ VERSION = "1.1.2"
3
3
  end
data/lib/bacon_tracker.rb CHANGED
@@ -418,14 +418,39 @@ module BaconTracker
418
418
  # The in-file title: the "Feature:" line of a .feature, the "Title:" line
419
419
  # of a .md. The filename slug is ASCII-only, so a humanized slug loses
420
420
  # umlauts, dots and case ("Datenschutzerklärung" shows as
421
- # "datenschutzerklaerung"); the in-file line is the title as typed. nil when
422
- # the line is missing or blank, so callers fall back to the slug.
421
+ # "datenschutzerklaerung"); the in-file line is the title as typed. A .md
422
+ # story without a Title: line that opens with a markdown heading takes that
423
+ # heading, the way decision records do (BT-183) - a third of the stories
424
+ # across Ingo's projects are written that way. nil when the line is missing
425
+ # or blank and the story does not open with a heading, so callers fall back
426
+ # to the slug.
423
427
  def story_title(body, ext)
424
428
  line = ext == '.feature' ? /^Feature:[ \t]*(.*)$/ : /^Title:[ \t]*(.*)$/
425
429
  title = body[line, 1]&.strip
430
+ title = body[LEADING_HEADING, 'text']&.strip if (title.nil? || title.empty?) && ext != '.feature'
426
431
  title unless title.nil? || title.empty?
427
432
  end
428
433
 
434
+ # A markdown heading (any level) as the first non-blank line of a story
435
+ # body: the title of a .md story that has no Title: line (BT-183). Only the
436
+ # opening line counts, so a "## Description" section further down, or a
437
+ # "# comment" inside a code fence, is never mistaken for the title.
438
+ LEADING_HEADING = /\A(?<lead>\s*\#{1,6}[ \t]+)(?<text>.+?)(?<tail>[ \t#]*)$/
439
+
440
+ # Rewrite the opening heading of a .md story (see LEADING_HEADING) to
441
+ # `title`, leaving any YAML frontmatter untouched. Content without such a
442
+ # heading is returned as is.
443
+ def retitle_leading_heading(content, title)
444
+ fm, body =
445
+ if content.start_with?("---\n")
446
+ close = content.index(/^---\n/, 4)
447
+ close ? [content[0...(close + 4)], content[(close + 4)..]] : ['', content]
448
+ else
449
+ ['', content]
450
+ end
451
+ fm + body.sub(LEADING_HEADING) { "#{$~[:lead]}#{title}#{$~[:tail]}" }
452
+ end
453
+
429
454
  # Delegates to set_frontmatter_field: the substitution stays scoped to the
430
455
  # frontmatter block (a body line like "status: healthy" is never touched)
431
456
  # and a missing status field is added instead of silently skipped.
@@ -522,7 +547,14 @@ module BaconTracker
522
547
  raise
523
548
  end
524
549
 
525
- # Normalize a T-shirt size to S/M/L (upcased), treating nil/"" as "unset"
550
+ # The T-shirt sizes the tracker knows. The one list behind creating,
551
+ # editing, showing and linting a story, so no entry point can accept a
552
+ # size another one hides (BT-184 - XS was refused on edit, created by the
553
+ # rake task, and shown as nothing by the board).
554
+ SIZES = %w[S M L].freeze
555
+ SIZE_RULE = "size must be #{SIZES[0...-1].join(', ')}, or #{SIZES.last}".freeze
556
+
557
+ # Normalize a T-shirt size to SIZES (upcased), treating nil/"" as "unset"
526
558
  # and raising on anything else - the one rule shared by create_story and
527
559
  # update_story so the two entry points can't disagree (BT-101).
528
560
  def normalized_size!(size)
@@ -531,11 +563,25 @@ module BaconTracker
531
563
  return nil if size.empty?
532
564
 
533
565
  up = size.upcase
534
- raise ArgumentError, 'size must be S, M, or L' unless %w[S M L].include?(up)
566
+ raise ArgumentError, SIZE_RULE unless SIZES.include?(up)
535
567
 
536
568
  up
537
569
  end
538
570
 
571
+ # Stories whose size: is not one the tracker knows (BT-184). story:lint
572
+ # reports them as a notice, not a failure: the value came from a hand edit
573
+ # or an older tool, the board already shows the story without a size, and
574
+ # a project is not made to sweep its old stories for a green build. Done
575
+ # stories are a record and are skipped, as relationship_findings does.
576
+ def unknown_sizes
577
+ all_stories.reject { |s| s[:stage] == '4_done' }.sort_by { |s| s[:id] }.filter_map do |s|
578
+ declared = s[:declared_size]
579
+ next if declared.nil? || SIZES.include?(declared.upcase)
580
+
581
+ { id: s[:id], size: declared, path: s[:path] }
582
+ end
583
+ end
584
+
539
585
  def move_to_stage(result, new_stage)
540
586
  old_path = result[:file]
541
587
  new_dir = File.join(@config.tracker_root, result[:dir], new_stage)
@@ -549,8 +595,44 @@ module BaconTracker
549
595
  new_path
550
596
  end
551
597
 
598
+ # The title a backlog.md line carries: the story's in-file title, the same
599
+ # one the board shows, so the line and the card never disagree (BT-186).
600
+ # The humanized slug is what parse_story_file falls back to when the file
601
+ # has no title line and no opening heading.
602
+ def backlog_title(path)
603
+ meta = story_dirs.find { |d| d[:path] == File.dirname(path) }
604
+ story = meta && parse_story_file(path, meta)
605
+ story ? story[:title] : humanize_slug(path)
606
+ end
607
+
608
+ # The title part of a backlog ENTRY line ("- NS-123 title" -> "title"),
609
+ # nil for a non-entry line. Compared against backlog_title by story:lint.
610
+ def backlog_line_title(line)
611
+ m = line.match(/\A\s*-\s+#{id_pattern}[ \t]*(.*?)\s*\z/)
612
+ m && m[1]
613
+ end
614
+
615
+ # Backlog entry lines whose title is not the story's (BT-186): the same
616
+ # comparison the estate's CI lint makes, so a tree that passes locally
617
+ # passes there. Lines without a file are phantoms and reported as such.
618
+ def backlog_title_drift
619
+ return [] unless File.exist?(@config.backlog_path)
620
+
621
+ on_disk = backlog_story_files.to_h { |f| [File.basename(f)[filename_id_pattern], f] }
622
+ File.readlines(@config.backlog_path, encoding: 'utf-8').each_with_index.filter_map do |line, i|
623
+ id = backlog_line_id(line)
624
+ next unless id && on_disk.key?(id)
625
+
626
+ listed = backlog_line_title(line)
627
+ title = backlog_title(on_disk[id])
628
+ next if listed == title
629
+
630
+ { id: id, listed: listed, title: title, path: on_disk[id], line: i + 1 }
631
+ end
632
+ end
633
+
552
634
  def backlog_add(id, filename)
553
- title = humanize_slug(filename)
635
+ title = backlog_title(filename)
554
636
  existing = File.exist?(@config.backlog_path) ? File.read(@config.backlog_path, encoding: 'utf-8') : ''
555
637
  # Normalize the trailing newline first so a hand-edited backlog.md that
556
638
  # doesn't end in one doesn't glue the new entry onto the last line
@@ -576,7 +658,7 @@ module BaconTracker
576
658
  lines = File.exist?(@config.backlog_path) ? File.readlines(@config.backlog_path, encoding: 'utf-8') : []
577
659
  kept = lines.select { |l| (id = backlog_line_id(l)).nil? || on_disk.key?(id) }
578
660
  listed = kept.filter_map { |l| backlog_line_id(l) }
579
- additions = (on_disk.keys - listed).map { |id| "- #{id} #{humanize_slug(on_disk[id])}\n" }
661
+ additions = (on_disk.keys - listed).map { |id| "- #{id} #{backlog_title(on_disk[id])}\n" }
580
662
  atomic_write(@config.backlog_path, (kept + additions).join) if kept != lines || additions.any?
581
663
  end
582
664
 
@@ -1090,8 +1172,15 @@ module BaconTracker
1090
1172
  }
1091
1173
  end
1092
1174
 
1175
+ # No syntax highlighter: kramdown defaults to Rouge and requires it on the
1176
+ # first fenced code block if it happens to be installed. Rouge activates
1177
+ # the strscan gem over the already-loaded default one, two StringScanner
1178
+ # types then live in the process, and every parse after that raises - the
1179
+ # whole docs surface goes dark until a restart. Plain <pre><code> is all
1180
+ # the page styles anyway.
1093
1181
  def render_markdown(text)
1094
- doc = Kramdown::Document.new(gfm_table_compat(text), input: 'GFM', hard_wrap: false)
1182
+ doc = Kramdown::Document.new(gfm_table_compat(text), input: 'GFM', hard_wrap: false,
1183
+ syntax_highlighter: nil)
1095
1184
  sanitize_tree!(doc.root)
1096
1185
  doc.to_html
1097
1186
  end
@@ -1572,7 +1661,11 @@ module BaconTracker
1572
1661
  # same set the `story:edit` task parses. A field-validation failure is
1573
1662
  # reported against the just-created story so its ID isn't lost.
1574
1663
  def create(kind, title, fields = {})
1575
- story = create_story(kind, title)
1664
+ fields = fields.dup
1665
+ # The size goes through create_story, which validates it before an ID is
1666
+ # consumed (BT-184): a refused size leaves no unsized story behind. The
1667
+ # remaining fields are set afterwards, as before.
1668
+ story = create_story(kind, title, size: fields.delete(:size))
1576
1669
  path =
1577
1670
  if fields.empty?
1578
1671
  story[:path]
@@ -1669,8 +1762,11 @@ module BaconTracker
1669
1762
  [{}, content]
1670
1763
  end
1671
1764
 
1672
- raw_size = fm['size']&.to_s&.upcase
1673
- size = %w[S M L].include?(raw_size) ? raw_size : nil
1765
+ # declared_size is the value as written; size is nil unless it is one
1766
+ # the tracker knows, and story:lint reports the difference (BT-184).
1767
+ declared_size = fm['size']&.to_s&.strip
1768
+ declared_size = nil if declared_size&.empty?
1769
+ size = declared_size && SIZES.include?(declared_size.upcase) ? declared_size.upcase : nil
1674
1770
 
1675
1771
  blocked_by = decision_refs(fm['blocked_by'])
1676
1772
  linked_to = decision_refs(fm['linked_to'])
@@ -1687,7 +1783,7 @@ module BaconTracker
1687
1783
  # Body-line addresses of the subtasks - the client renders/toggles by
1688
1784
  # these instead of re-deriving fence rules (the BT-049 drift class).
1689
1785
  subtask_lines: st_lines.empty? ? nil : st_lines,
1690
- declared_status: fm['status']&.to_s }
1786
+ declared_status: fm['status']&.to_s, declared_size: declared_size }
1691
1787
  end
1692
1788
 
1693
1789
  # Indices of body lines that are subtasks, skipping fenced content
@@ -1887,13 +1983,8 @@ module BaconTracker
1887
1983
  else
1888
1984
  Templates.for(ext, kind)
1889
1985
  end
1890
- filled = if ext == '.feature'
1891
- template.sub('Name the Feature') { title }
1892
- else
1893
- template.sub(/\ATitle:.*/) { "Title: #{title}" }
1894
- end
1895
1986
  fm = kind == 'feature' ? frontmatter_feature(id, kind, status) : frontmatter_md(id, kind, status)
1896
- content = fm + filled
1987
+ content = fm + fill_template(template, ext, title)
1897
1988
 
1898
1989
  atomic_write(dest, content)
1899
1990
  backlog_add(id, dest) if stage == '2_backlog'
@@ -1903,6 +1994,52 @@ module BaconTracker
1903
1994
  end
1904
1995
  end
1905
1996
 
1997
+ # The body of a new story: the template without any header block of its
1998
+ # own, with the title filled into its first Title:/Feature: line. A
1999
+ # template that starts with a header (id/type/status, a placeholder id)
2000
+ # used to be copied as is, so the new story carried two headers and the
2001
+ # template's placeholder title (BT-185). A template without a title line
2002
+ # gets one, so the title as typed always reaches the file.
2003
+ def fill_template(template, ext, title)
2004
+ body = strip_template_header(template)
2005
+ line = ext == '.feature' ? 'Feature' : 'Title'
2006
+ if body.match?(/^#{line}:.*$/)
2007
+ body.sub(/^#{line}:.*$/) { "#{line}: #{title}" }
2008
+ else
2009
+ "#{line}: #{title}\n\n#{body}"
2010
+ end
2011
+ end
2012
+
2013
+ # The header block a template starts with, if any - a YAML '---' block in
2014
+ # either dialect, or the '# key: value' lines of a .feature. Returns the
2015
+ # body after it (leading blank lines dropped), or the template unchanged.
2016
+ def strip_template_header(template)
2017
+ if template.start_with?("---\n")
2018
+ close = template.index(/^---\n/, 4)
2019
+ close ? template[(close + 4)..].lstrip : template
2020
+ elsif template.match?(/\A#{FEATURE_HEADER}/)
2021
+ template.lines.drop_while { |l| l.match?(FEATURE_HEADER) }.join.lstrip
2022
+ else
2023
+ template
2024
+ end
2025
+ end
2026
+
2027
+ # Story templates that start with a header block of their own (BT-185).
2028
+ # create_story drops it, but the /tracker command's file steps and anyone
2029
+ # copying the template by hand would not, so story:lint points it out and
2030
+ # the project can take it out of the template.
2031
+ def template_headers
2032
+ STORY_DIRS.filter_map do |dir, m|
2033
+ path = File.join(@config.tracker_root, dir, "_template#{m[:ext]}")
2034
+ next unless File.exist?(path)
2035
+
2036
+ template = File.read(path, encoding: 'utf-8')
2037
+ next if strip_template_header(template).equal?(template)
2038
+
2039
+ { path: path, dir: dir }
2040
+ end
2041
+ end
2042
+
1906
2043
  def delete_story(id)
1907
2044
  with_lock do
1908
2045
  result = find_story(id)
@@ -1964,8 +2101,12 @@ module BaconTracker
1964
2101
  if title
1965
2102
  content = if ext == '.feature'
1966
2103
  content.sub(/^Feature: .+/) { "Feature: #{title}" }
1967
- else
2104
+ elsif content.match?(/^Title: .+/)
1968
2105
  content.sub(/^Title: .+/) { "Title: #{title}" }
2106
+ else
2107
+ # A story titled by its opening heading (BT-183) is retitled
2108
+ # in that heading, so the card keeps following the file.
2109
+ retitle_leading_heading(content, title)
1969
2110
  end
1970
2111
  end
1971
2112
 
@@ -2001,11 +2142,13 @@ module BaconTracker
2001
2142
  if path != new_path
2002
2143
  FileUtils.mv(path, new_path)
2003
2144
  path = new_path
2004
- if result[:stage] == '2_backlog' && File.exist?(@config.backlog_path)
2005
- lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
2006
- atomic_write(@config.backlog_path,
2007
- lines.map { |l| backlog_line_id(l) == id ? "- #{id} #{title}\n" : l }.join)
2008
- end
2145
+ end
2146
+ # The backlog line follows the title even when the slug, and so the
2147
+ # filename, did not change ("Fix bug" -> "Fix bug!") - BT-186.
2148
+ if title && result[:stage] == '2_backlog' && File.exist?(@config.backlog_path)
2149
+ lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
2150
+ atomic_write(@config.backlog_path,
2151
+ lines.map { |l| backlog_line_id(l) == id ? "- #{id} #{title}\n" : l }.join)
2009
2152
  end
2010
2153
  end
2011
2154
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bacon-tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Berger