bacon-tracker 1.0.0 → 1.1.1

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: 69adddc52e57d700e447f7090e300ff58cf74c3ca9881eb54c19b488f39222d7
4
- data.tar.gz: 5efcc62978f6d2aeb3689649298956be52ab3fba0bef0ac3ae55603c2afc584f
3
+ metadata.gz: 42440d405b85d2a4c75b2d12dc9f76a5519e694ecd88e10b208d008bc8f8a6ad
4
+ data.tar.gz: 2a7a38d3156579ec88136713ecc60f8b1bf35a35d57f4e9141000be7a9ac2946
5
5
  SHA512:
6
- metadata.gz: 202a6ab1a3974e6a303d6571500c230a968114e85003db38c9206908701a51472566f01523637ee032507a1d060df5c2b1272dec001d0a4b76275d64c374628a
7
- data.tar.gz: 9c33747da78d15551c351a884c81ff3a1ef1284079fc6dd627d0284ec6fada9f5ba3f4ec3586f06bf6a56ac958e87a63b066d0a303b7445d60a94f8572b88487
6
+ metadata.gz: c85377256c359817bf27bae870babd0f34fe724d5b1f117eb0a61c216ea50fc18dbb5bfbded6d105286f9d146ffe9c315e83b1d7b5bfd70e1ae5014e2c865f1d
7
+ data.tar.gz: d57fc7d4fb694b8729751b92d04d34921de3099ded4fcd2a52eadd2e665ceae33f66129663198279dd489b30c242f41f683f081ec5bea48e730d0f506eba5d82
data/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
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
+ ## [1.1.1] - 2026-10-21
7
+
8
+ ### Fixed
9
+
10
+ - `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.
11
+ - `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.
12
+ - 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.
13
+ - `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.
14
+ - `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.
15
+ - 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.
16
+
17
+ ## [1.1.0] - 2026-10-01
18
+
19
+ ### Changed
20
+
21
+ - Filename slugs spell out `ä`, `ö`, `ü`, `ß` as `ae`, `oe`, `ue`, `ss` and drop accents from other letters, so "Datenschutzerklärung" becomes `datenschutzerklaerung` instead of `datenschutzerkl-rung`. Existing files keep their names until the story is retitled.
22
+ - A title with nothing the slug rule keeps (all-CJK, all-punctuation) now slugs to `non-latin-REPLACE-ME` instead of `untitled`, for story files, decision records and dashboard project slugs alike. The dashboard used to get an empty project slug in that case.
23
+
24
+ ### Fixed
25
+
26
+ - Story titles on the board and in the API now come from the in-file `Title:`/`Feature:` line, so umlauts, dots and capitals survive. They were humanized from the filename slug, which keeps only ASCII letters and digits ("Datenschutzerklärung" showed as "datenschutzerkl rung", "bybacon.com" as "bybacon com"). The slug remains the fallback when the line is missing.
27
+
6
28
  ## [1.0.0] - 2026-09-25
7
29
 
8
30
  First public release.
@@ -105,9 +105,9 @@ type must be `feature`, `bug`, or `chore`. The title is everything after the typ
105
105
  1. Determine N: read the integer from `$TRACKER_ROOT/.next-id`, then **floor it above the highest `<NS>-NNN` already on disk** (`N = max(read, highest + 1)`). A stale `.next-id` from a merge or hand edit must never reissue an existing ID. When `rake` is available, prefer `rake story:<type>['title',field=…]`, which allocates the ID atomically (with this floor, under a file lock).
106
106
  2. Write `N+1` back to `.next-id`
107
107
  3. Pad N to 3 digits: `NNN`
108
- 4. Slugify the title: 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 `untitled`
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 (humanised from filename), 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
@@ -264,6 +264,6 @@ Report each finding with the ID and file path, or confirm all checks pass. Don't
264
264
 
265
265
  - Expand `~` in any paths using shell: `eval echo <path>` or `realpath`.
266
266
  - When editing frontmatter, match the existing format (comment-style for `.feature`, YAML for `.md`).
267
- - Slugify: `echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'` - and use `untitled` if the result is empty
267
+ - Slugify: `echo "$TITLE" | sed 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g; s/Ä/Ae/g; s/Ö/Oe/g; s/Ü/Ue/g' | perl -CS -MUnicode::Normalize -pe '$_ = NFKD($_); s/\p{Mn}//g' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'` - and use `non-latin-REPLACE-ME` if the result is empty
268
268
  - `backlog.md` line format: `- <NS>-NNN <title>` (leading dash, single space - exactly what the gem writes; the stats and next-task parsers depend on it). Preserve this when adding/removing entries.
269
269
  - Keep output concise - one line per story, full content only for `show`.
@@ -136,11 +136,15 @@ module BaconTracker
136
136
  return nil
137
137
  end
138
138
 
139
+ # A name with nothing the slug rule keeps gets the EMPTY_SLUG placeholder
140
+ # as its route (never an empty "/projects/"), but a namespace must not be
141
+ # guessed from it: stories are matched by "<NS>-NNN", so an explicit
142
+ # 'namespace:' is required.
139
143
  slug = BaconTracker.slugify(item[:name])
140
- namespace = item[:attrs]['namespace'] || slug.upcase.gsub('-', '_')
144
+ namespace = item[:attrs]['namespace'] || (slug == BaconTracker::EMPTY_SLUG ? '' : slug.upcase.gsub('-', '_'))
141
145
  if namespace.empty?
142
146
  warn "[BaconTracker] dashboard.md: skipping project #{item[:name].inspect} - " \
143
- "empty namespace (its name has no letters/digits; add an explicit 'namespace:')"
147
+ "no usable namespace (its name has no letters/digits; add an explicit 'namespace:')"
144
148
  return nil
145
149
  end
146
150
 
@@ -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.0.0"
2
+ VERSION = "1.1.1"
3
3
  end
data/lib/bacon_tracker.rb CHANGED
@@ -170,11 +170,32 @@ module BaconTracker
170
170
  @config || configure
171
171
  end
172
172
 
173
+ # German letters have a spelled-out ASCII form that readers expect in a
174
+ # filename ("datenschutzerklaerung", not "datenschutzerkl-rung"); every
175
+ # other accented letter loses its mark via NFKD decomposition (é -> e).
176
+ TRANSLITERATIONS = {
177
+ 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
178
+ 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ẞ' => 'SS'
179
+ }.freeze
180
+
181
+ # What slugify returns when nothing of the title survives the ASCII rule
182
+ # (all-CJK, all-punctuation). Never "": an empty slug makes a dangling
183
+ # "<id>-.md" that is not a record (BT-095) and a dashboard route of
184
+ # "/projects/". The placeholder says what happened and asks for a hand-picked
185
+ # slug; the real title stays in the file's Title:/Feature: line.
186
+ EMPTY_SLUG = 'non-latin-REPLACE-ME'
187
+
173
188
  # Filesystem-safe slug from a human title - the single source shared by
174
189
  # Core (story filenames) and Dashboard (project slugs) so the two can never
175
- # drift apart.
190
+ # drift apart. ASCII-only: the German letters are transliterated, other
191
+ # accents stripped, and whatever is still not [a-z0-9] becomes a hyphen.
192
+ # Never empty - see EMPTY_SLUG.
176
193
  def self.slugify(title)
177
- title.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')
194
+ slug = title.to_s
195
+ .gsub(/[äöüßÄÖÜẞ]/, TRANSLITERATIONS)
196
+ .unicode_normalize(:nfkd).gsub(/\p{Mn}/, '')
197
+ .downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')
198
+ slug.empty? ? EMPTY_SLUG : slug
178
199
  end
179
200
 
180
201
  class Core
@@ -313,16 +334,6 @@ module BaconTracker
313
334
  BaconTracker.slugify(title)
314
335
  end
315
336
 
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
337
  def story_dirs
327
338
  STORY_DIRS.flat_map do |dir, meta|
328
339
  STAGES.map do |stage|
@@ -404,6 +415,42 @@ module BaconTracker
404
415
  base.sub(/\A#{id_pattern}-/, '').gsub('-', ' ')
405
416
  end
406
417
 
418
+ # The in-file title: the "Feature:" line of a .feature, the "Title:" line
419
+ # of a .md. The filename slug is ASCII-only, so a humanized slug loses
420
+ # umlauts, dots and case ("Datenschutzerklärung" shows as
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.
427
+ def story_title(body, ext)
428
+ line = ext == '.feature' ? /^Feature:[ \t]*(.*)$/ : /^Title:[ \t]*(.*)$/
429
+ title = body[line, 1]&.strip
430
+ title = body[LEADING_HEADING, 'text']&.strip if (title.nil? || title.empty?) && ext != '.feature'
431
+ title unless title.nil? || title.empty?
432
+ end
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
+
407
454
  # Delegates to set_frontmatter_field: the substitution stays scoped to the
408
455
  # frontmatter block (a body line like "status: healthy" is never touched)
409
456
  # and a missing status field is added instead of silently skipped.
@@ -500,7 +547,14 @@ module BaconTracker
500
547
  raise
501
548
  end
502
549
 
503
- # 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"
504
558
  # and raising on anything else - the one rule shared by create_story and
505
559
  # update_story so the two entry points can't disagree (BT-101).
506
560
  def normalized_size!(size)
@@ -509,11 +563,25 @@ module BaconTracker
509
563
  return nil if size.empty?
510
564
 
511
565
  up = size.upcase
512
- 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)
513
567
 
514
568
  up
515
569
  end
516
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
+
517
585
  def move_to_stage(result, new_stage)
518
586
  old_path = result[:file]
519
587
  new_dir = File.join(@config.tracker_root, result[:dir], new_stage)
@@ -527,8 +595,44 @@ module BaconTracker
527
595
  new_path
528
596
  end
529
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
+
530
634
  def backlog_add(id, filename)
531
- title = humanize_slug(filename)
635
+ title = backlog_title(filename)
532
636
  existing = File.exist?(@config.backlog_path) ? File.read(@config.backlog_path, encoding: 'utf-8') : ''
533
637
  # Normalize the trailing newline first so a hand-edited backlog.md that
534
638
  # doesn't end in one doesn't glue the new entry onto the last line
@@ -554,7 +658,7 @@ module BaconTracker
554
658
  lines = File.exist?(@config.backlog_path) ? File.readlines(@config.backlog_path, encoding: 'utf-8') : []
555
659
  kept = lines.select { |l| (id = backlog_line_id(l)).nil? || on_disk.key?(id) }
556
660
  listed = kept.filter_map { |l| backlog_line_id(l) }
557
- 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" }
558
662
  atomic_write(@config.backlog_path, (kept + additions).join) if kept != lines || additions.any?
559
663
  end
560
664
 
@@ -1324,7 +1428,7 @@ module BaconTracker
1324
1428
  with_decisions_lock do
1325
1429
  heal_proposed!
1326
1430
  id = format_decision_id(consume_decision_id)
1327
- slug = filename_slug(title) # never "<id>-.md", which is not a record
1431
+ slug = slugify(title) # never "<id>-.md", which is not a record (EMPTY_SLUG)
1328
1432
  template_path = File.join(root, '_template.md')
1329
1433
  body = File.exist?(template_path) ? File.read(template_path, encoding: 'utf-8') : Templates::DECISION
1330
1434
  today = Date.today.iso8601
@@ -1550,7 +1654,11 @@ module BaconTracker
1550
1654
  # same set the `story:edit` task parses. A field-validation failure is
1551
1655
  # reported against the just-created story so its ID isn't lost.
1552
1656
  def create(kind, title, fields = {})
1553
- story = create_story(kind, title)
1657
+ fields = fields.dup
1658
+ # The size goes through create_story, which validates it before an ID is
1659
+ # consumed (BT-184): a refused size leaves no unsized story behind. The
1660
+ # remaining fields are set afterwards, as before.
1661
+ story = create_story(kind, title, size: fields.delete(:size))
1554
1662
  path =
1555
1663
  if fields.empty?
1556
1664
  story[:path]
@@ -1647,8 +1755,11 @@ module BaconTracker
1647
1755
  [{}, content]
1648
1756
  end
1649
1757
 
1650
- raw_size = fm['size']&.to_s&.upcase
1651
- size = %w[S M L].include?(raw_size) ? raw_size : nil
1758
+ # declared_size is the value as written; size is nil unless it is one
1759
+ # the tracker knows, and story:lint reports the difference (BT-184).
1760
+ declared_size = fm['size']&.to_s&.strip
1761
+ declared_size = nil if declared_size&.empty?
1762
+ size = declared_size && SIZES.include?(declared_size.upcase) ? declared_size.upcase : nil
1652
1763
 
1653
1764
  blocked_by = decision_refs(fm['blocked_by'])
1654
1765
  linked_to = decision_refs(fm['linked_to'])
@@ -1660,12 +1771,12 @@ module BaconTracker
1660
1771
  subtasks = subtask_counts(body, ext)
1661
1772
 
1662
1773
  { 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,
1774
+ path: path, title: story_title(body, ext) || humanize_slug(path), body: body, size: size,
1664
1775
  blocked_by: blocked_by, linked_to: linked_to, assignee: assignee, subtasks: subtasks,
1665
1776
  # Body-line addresses of the subtasks - the client renders/toggles by
1666
1777
  # these instead of re-deriving fence rules (the BT-049 drift class).
1667
1778
  subtask_lines: st_lines.empty? ? nil : st_lines,
1668
- declared_status: fm['status']&.to_s }
1779
+ declared_status: fm['status']&.to_s, declared_size: declared_size }
1669
1780
  end
1670
1781
 
1671
1782
  # Indices of body lines that are subtasks, skipping fenced content
@@ -1851,7 +1962,7 @@ module BaconTracker
1851
1962
  with_lock do
1852
1963
  id_num = consume_id
1853
1964
  id = format_id(id_num)
1854
- slug = filename_slug(title)
1965
+ slug = slugify(title)
1855
1966
 
1856
1967
  filename = "#{id}-#{slug}#{ext}"
1857
1968
  dest_dir = File.join(@config.tracker_root, dir, stage)
@@ -1865,13 +1976,8 @@ module BaconTracker
1865
1976
  else
1866
1977
  Templates.for(ext, kind)
1867
1978
  end
1868
- filled = if ext == '.feature'
1869
- template.sub('Name the Feature') { title }
1870
- else
1871
- template.sub(/\ATitle:.*/) { "Title: #{title}" }
1872
- end
1873
1979
  fm = kind == 'feature' ? frontmatter_feature(id, kind, status) : frontmatter_md(id, kind, status)
1874
- content = fm + filled
1980
+ content = fm + fill_template(template, ext, title)
1875
1981
 
1876
1982
  atomic_write(dest, content)
1877
1983
  backlog_add(id, dest) if stage == '2_backlog'
@@ -1881,6 +1987,52 @@ module BaconTracker
1881
1987
  end
1882
1988
  end
1883
1989
 
1990
+ # The body of a new story: the template without any header block of its
1991
+ # own, with the title filled into its first Title:/Feature: line. A
1992
+ # template that starts with a header (id/type/status, a placeholder id)
1993
+ # used to be copied as is, so the new story carried two headers and the
1994
+ # template's placeholder title (BT-185). A template without a title line
1995
+ # gets one, so the title as typed always reaches the file.
1996
+ def fill_template(template, ext, title)
1997
+ body = strip_template_header(template)
1998
+ line = ext == '.feature' ? 'Feature' : 'Title'
1999
+ if body.match?(/^#{line}:.*$/)
2000
+ body.sub(/^#{line}:.*$/) { "#{line}: #{title}" }
2001
+ else
2002
+ "#{line}: #{title}\n\n#{body}"
2003
+ end
2004
+ end
2005
+
2006
+ # The header block a template starts with, if any - a YAML '---' block in
2007
+ # either dialect, or the '# key: value' lines of a .feature. Returns the
2008
+ # body after it (leading blank lines dropped), or the template unchanged.
2009
+ def strip_template_header(template)
2010
+ if template.start_with?("---\n")
2011
+ close = template.index(/^---\n/, 4)
2012
+ close ? template[(close + 4)..].lstrip : template
2013
+ elsif template.match?(/\A#{FEATURE_HEADER}/)
2014
+ template.lines.drop_while { |l| l.match?(FEATURE_HEADER) }.join.lstrip
2015
+ else
2016
+ template
2017
+ end
2018
+ end
2019
+
2020
+ # Story templates that start with a header block of their own (BT-185).
2021
+ # create_story drops it, but the /tracker command's file steps and anyone
2022
+ # copying the template by hand would not, so story:lint points it out and
2023
+ # the project can take it out of the template.
2024
+ def template_headers
2025
+ STORY_DIRS.filter_map do |dir, m|
2026
+ path = File.join(@config.tracker_root, dir, "_template#{m[:ext]}")
2027
+ next unless File.exist?(path)
2028
+
2029
+ template = File.read(path, encoding: 'utf-8')
2030
+ next if strip_template_header(template).equal?(template)
2031
+
2032
+ { path: path, dir: dir }
2033
+ end
2034
+ end
2035
+
1884
2036
  def delete_story(id)
1885
2037
  with_lock do
1886
2038
  result = find_story(id)
@@ -1942,8 +2094,12 @@ module BaconTracker
1942
2094
  if title
1943
2095
  content = if ext == '.feature'
1944
2096
  content.sub(/^Feature: .+/) { "Feature: #{title}" }
1945
- else
2097
+ elsif content.match?(/^Title: .+/)
1946
2098
  content.sub(/^Title: .+/) { "Title: #{title}" }
2099
+ else
2100
+ # A story titled by its opening heading (BT-183) is retitled
2101
+ # in that heading, so the card keeps following the file.
2102
+ retitle_leading_heading(content, title)
1947
2103
  end
1948
2104
  end
1949
2105
 
@@ -1975,15 +2131,17 @@ module BaconTracker
1975
2131
 
1976
2132
  if title || body || !size.nil? || !blocked_by.nil? || !linked_to.nil? || !assignee.nil?
1977
2133
  atomic_write(path, content)
1978
- new_path = title ? File.join(File.dirname(path), "#{id}-#{filename_slug(title)}#{ext}") : path
2134
+ new_path = title ? File.join(File.dirname(path), "#{id}-#{slugify(title)}#{ext}") : path
1979
2135
  if path != new_path
1980
2136
  FileUtils.mv(path, new_path)
1981
2137
  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
2138
+ end
2139
+ # The backlog line follows the title even when the slug, and so the
2140
+ # filename, did not change ("Fix bug" -> "Fix bug!") - BT-186.
2141
+ if title && result[:stage] == '2_backlog' && File.exist?(@config.backlog_path)
2142
+ lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
2143
+ atomic_write(@config.backlog_path,
2144
+ lines.map { |l| backlog_line_id(l) == id ? "- #{id} #{title}\n" : l }.join)
1987
2145
  end
1988
2146
  end
1989
2147
 
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.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Berger