Almirah 0.4.2 → 0.4.4

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/bin/almirah +2 -2
  3. data/lib/almirah/doc_fabric.rb +25 -0
  4. data/lib/almirah/doc_items/blockquote.rb +15 -16
  5. data/lib/almirah/doc_items/code_block.rb +19 -21
  6. data/lib/almirah/doc_items/controlled_paragraph.rb +4 -4
  7. data/lib/almirah/doc_items/controlled_table.rb +11 -11
  8. data/lib/almirah/doc_items/controlled_table_row.rb +15 -18
  9. data/lib/almirah/doc_items/doc_footer.rb +10 -13
  10. data/lib/almirah/doc_items/doc_item.rb +2 -2
  11. data/lib/almirah/doc_items/heading.rb +1 -1
  12. data/lib/almirah/doc_items/image.rb +25 -27
  13. data/lib/almirah/doc_items/markdown_list.rb +2 -2
  14. data/lib/almirah/doc_items/markdown_table.rb +9 -2
  15. data/lib/almirah/doc_items/scope_table.rb +188 -0
  16. data/lib/almirah/doc_items/text_line.rb +26 -22
  17. data/lib/almirah/doc_items/todo_block.rb +15 -16
  18. data/lib/almirah/doc_items/work_item.rb +129 -0
  19. data/lib/almirah/doc_parser.rb +15 -8
  20. data/lib/almirah/doc_types/base_document.rb +31 -7
  21. data/lib/almirah/doc_types/coverage.rb +3 -3
  22. data/lib/almirah/doc_types/critical_chain_page.rb +218 -0
  23. data/lib/almirah/doc_types/decision.rb +152 -7
  24. data/lib/almirah/doc_types/decision_grouping.rb +17 -0
  25. data/lib/almirah/doc_types/decisions_overview.rb +591 -31
  26. data/lib/almirah/doc_types/implementation.rb +98 -98
  27. data/lib/almirah/doc_types/index.rb +11 -12
  28. data/lib/almirah/doc_types/persistent_document.rb +1 -1
  29. data/lib/almirah/doc_types/planning_dates.rb +17 -0
  30. data/lib/almirah/doc_types/protocol.rb +16 -20
  31. data/lib/almirah/doc_types/risk_record.rb +77 -0
  32. data/lib/almirah/doc_types/risk_registry_page.rb +141 -0
  33. data/lib/almirah/doc_types/risks_overview.rb +102 -0
  34. data/lib/almirah/doc_types/rpn_rendering.rb +23 -0
  35. data/lib/almirah/doc_types/source_file.rb +1 -1
  36. data/lib/almirah/doc_types/traceability.rb +124 -133
  37. data/lib/almirah/dom/doc_section.rb +1 -1
  38. data/lib/almirah/navigation_pane.rb +9 -13
  39. data/lib/almirah/project/critical_chain.rb +117 -0
  40. data/lib/almirah/project/doc_linker.rb +4 -4
  41. data/lib/almirah/project/fever_chart.rb +94 -0
  42. data/lib/almirah/project/project_data.rb +17 -2
  43. data/lib/almirah/project/work_item_scheduler.rb +167 -0
  44. data/lib/almirah/project/working_calendar.rb +112 -0
  45. data/lib/almirah/project.rb +307 -9
  46. data/lib/almirah/project_configuration.rb +176 -29
  47. data/lib/almirah/project_template.rb +6 -6
  48. data/lib/almirah/project_utility.rb +3 -5
  49. data/lib/almirah/search/specifications_db.rb +2 -2
  50. data/lib/almirah/source_file_parser.rb +2 -3
  51. data/lib/almirah/templates/css/main.css +173 -1
  52. data/lib/almirah/templates/scripts/main.js +3 -1
  53. data/lib/almirah.rb +1 -2
  54. metadata +14 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require_relative 'doc_fabric'
5
+ require_relative 'doc_items/work_item'
5
6
  require_relative 'navigation_pane'
6
7
  require_relative 'doc_types/traceability'
7
8
  require_relative 'doc_types/index'
@@ -12,7 +13,7 @@ require_relative 'project/project_data'
12
13
  require_relative 'console_reporter'
13
14
  require_relative 'relative_url'
14
15
 
15
- class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
16
+ class Project
16
17
  attr_accessor :index, :project, :configuration, :project_data
17
18
 
18
19
  def initialize(configuration)
@@ -39,17 +40,20 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
39
40
  FileUtils.copy_entry(src_folder, dst_folder)
40
41
  end
41
42
 
42
- def specifications_and_protocols # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
43
+ def specifications_and_protocols
43
44
  parse_all_specifications
44
45
  parse_all_protocols
45
46
  parse_all_source_files
46
47
  parse_decisions
48
+ parse_risks
47
49
  link_all_specifications
48
50
  link_all_protocols
49
51
  link_all_source_files
50
52
  link_all_decisions
53
+ link_all_risks
51
54
  check_wrong_specification_referenced
52
55
  build_link_registry
56
+ link_work_items
53
57
  create_index
54
58
  render_all_specifications(@project_data.specifications)
55
59
  render_all_specifications(@project_data.traceability_matrices)
@@ -58,24 +62,32 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
58
62
  render_all_source_files
59
63
  render_all_specifications(@project_data.implementation_matrices) # intentionally after source file rendering
60
64
  render_decisions_overview
65
+ render_critical_chain_page
61
66
  render_all_decisions
67
+ render_all_risk_records
68
+ render_risk_registry_pages
69
+ render_risks_overview
62
70
  render_index
63
71
  create_search_data
64
72
  report_broken_links
73
+ report_kit_violations
65
74
  report_rendered
66
75
  end
67
76
 
68
- def specifications_and_results(test_run) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
77
+ def specifications_and_results(test_run)
69
78
  parse_all_specifications
70
79
  parse_test_run test_run
71
80
  parse_all_source_files
72
81
  parse_decisions
82
+ parse_risks
73
83
  link_all_specifications
74
84
  link_all_protocols
75
85
  link_all_source_files
76
86
  link_all_decisions
87
+ link_all_risks
77
88
  check_wrong_specification_referenced
78
89
  build_link_registry
90
+ link_work_items
79
91
  create_index
80
92
  render_all_specifications(@project_data.specifications)
81
93
  render_all_specifications(@project_data.traceability_matrices)
@@ -84,10 +96,15 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
84
96
  render_all_source_files
85
97
  render_all_specifications(@project_data.implementation_matrices) # intentionally after source file rendering
86
98
  render_decisions_overview
99
+ render_critical_chain_page
87
100
  render_all_decisions
101
+ render_all_risk_records
102
+ render_risk_registry_pages
103
+ render_risks_overview
88
104
  render_index
89
105
  create_search_data
90
106
  report_broken_links
107
+ report_kit_violations
91
108
  report_rendered
92
109
  end
93
110
 
@@ -107,10 +124,115 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
107
124
  broken.each { |b| puts ConsoleReporter.warn_detail(" #{b[:document] || '?'}: #{b[:target]}") }
108
125
  end
109
126
 
127
+ # Builds the per-row WorkItem dependency network (ADR-194): registers every
128
+ # Scope-row work item, fills the intra-record step-order edges, then resolves
129
+ # each Depends On reference globally (LinkRegistry) to the activity-type-aligned
130
+ # work item of the target record, tagging each cross-record edge in-group or
131
+ # cross-group against the decision_groups boundary (ADR-197). Unresolved
132
+ # references are collected for report_kit_violations. Runs after
133
+ # build_link_registry (so every record resolves) and before rendering (so the
134
+ # overview Kit column is ready).
135
+ def link_work_items
136
+ @kit_unresolved = []
137
+ @project_data.decisions.each do |d|
138
+ d.scope_work_items.each { |wi| @project_data.work_items[wi.id] = wi }
139
+ end
140
+ link_intra_record_steps
141
+ link_cross_record_dependencies
142
+ end
143
+
144
+ # Each row's lower-numbered same-record steps are its predecessors; equal step
145
+ # numbers are concurrent (no edge). These edges are in-group by definition.
146
+ def link_intra_record_steps
147
+ @project_data.decisions.each do |d|
148
+ items = d.scope_work_items
149
+ items.each do |wi|
150
+ items.each do |other|
151
+ next if other.equal?(wi) || other.step >= wi.step
152
+
153
+ wi.add_predecessor(other, cross_group: false)
154
+ other.add_successor(wi)
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ def link_cross_record_dependencies
161
+ @project_data.decisions.each do |d|
162
+ d.scope_work_items.each do |wi|
163
+ wi.depends_on_refs.each { |ref| link_dependency(d, wi, ref) }
164
+ end
165
+ end
166
+ end
167
+
168
+ def link_dependency(record, work_item, ref)
169
+ target = @project_data.link_registry.find_by_id(ref)
170
+ unless target.is_a?(Decision)
171
+ @kit_unresolved << { record: record.id, target: ref }
172
+ return
173
+ end
174
+ prereq = aligned_work_item(target, work_item.activity)
175
+ return if prereq.nil? || prereq.equal?(work_item)
176
+
177
+ cross = decision_group_name(record) != decision_group_name(target)
178
+ work_item.add_predecessor(prereq, cross_group: cross)
179
+ prereq.add_successor(work_item)
180
+ anchor = target.scope_table&.step_column? ? prereq.row_anchor : nil
181
+ work_item.add_resolved_dependency(ref, target, anchor, prereq.id)
182
+ end
183
+
184
+ # The target record's work item whose activity (Item) matches `activity`,
185
+ # falling back to the nearest earlier activity by canonical phase order, then
186
+ # (when the target has only later activities) to its earliest row. nil only
187
+ # when the target has no Scope rows.
188
+ def aligned_work_item(target, activity)
189
+ items = target.scope_work_items
190
+ return nil if items.empty?
191
+
192
+ exact = items.select { |t| t.activity == activity }.min_by(&:step)
193
+ return exact if exact
194
+
195
+ rank = WorkItem::ACTIVITY_ORDER.index(activity) || WorkItem::ACTIVITY_ORDER.length
196
+ earlier = items.select { |t| t.activity_rank <= rank }
197
+ return items.min_by { |t| [t.activity_rank, t.step] } if earlier.empty?
198
+
199
+ earlier.min_by { |t| [-t.activity_rank, t.step] }
200
+ end
201
+
202
+ # The planning-group name (first-level decisions/ folder) a record belongs to,
203
+ # read from the decision_groups collection (ADR-197).
204
+ def decision_group_name(doc)
205
+ group = @project_data.decision_groups.find { |g| g.values.first.include?(doc) }
206
+ group&.keys&.first
207
+ end
208
+
209
+ # Reports the two kit gates and unresolved Depends On references (ADR-194), all
210
+ # as non-failing console warnings alongside report_broken_links.
211
+ def report_kit_violations
212
+ @kit_unresolved ||= []
213
+ phase = @project_data.work_items.each_value.select(&:phase_order_violation?)
214
+ cross = @project_data.work_items.each_value.select(&:cross_record_violation?)
215
+ total = phase.length + cross.length + @kit_unresolved.length
216
+ return if total.zero?
217
+
218
+ ConsoleReporter.warn('kit violations', total)
219
+ phase.each do |wi|
220
+ blocking = wi.intra_record_predecessors.reject(&:done?).map(&:id).join(', ')
221
+ puts ConsoleReporter.warn_detail(" phase order: #{wi.id} started before #{blocking}")
222
+ end
223
+ cross.each do |wi|
224
+ blocking = wi.cross_record_predecessors.reject(&:done?).map(&:id).join(', ')
225
+ puts ConsoleReporter.warn_detail(" not kitted: #{wi.id} needs #{blocking}")
226
+ end
227
+ @kit_unresolved.each do |u|
228
+ puts ConsoleReporter.warn_detail(" unresolved Depends On: #{u[:record]} -> #{u[:target]}")
229
+ end
230
+ end
231
+
110
232
  # Assigns each document its generated output path (relative to the build root)
111
233
  # and registers it for cross-document link resolution (ADR-186). Runs after all
112
234
  # documents are parsed and before any rendering, so link targets are known.
113
- def build_link_registry # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
235
+ def build_link_registry
114
236
  reg = @project_data.link_registry
115
237
  TextLine.link_registry = reg
116
238
  TextLine.reset_broken_links
@@ -126,6 +248,10 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
126
248
  d.output_rel_path = "decisions/#{d.html_rel_path}"
127
249
  reg.register(d)
128
250
  end
251
+ @project_data.risk_records.each do |d|
252
+ d.output_rel_path = "risks/#{d.html_rel_path}"
253
+ reg.register(d)
254
+ end
129
255
  @project_data.source_files.each do |d|
130
256
  rel = d.path.sub("#{d.root_path}/", '')
131
257
  d.output_rel_path = "source_files/#{d.repository}/#{rel}.html"
@@ -177,11 +303,97 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
177
303
  rel_dir = File.dirname(f.sub("#{decisions_root}/", ''))
178
304
  doc.html_rel_path = rel_dir == '.' ? "#{doc.id}.html" : "#{rel_dir}/#{doc.id}.html"
179
305
  @project_data.decisions.append(doc)
306
+ add_to_decision_group(doc, rel_dir)
180
307
  end
181
308
  BaseDocument.show_decisions_link = @project_data.decisions.any?
182
309
  ConsoleReporter.count('parsing decisions', @project_data.decisions.length)
183
310
  end
184
311
 
312
+ # Add a decision record to its planning group, keyed on the first-level folder
313
+ # under decisions/ (a record directly under decisions/ has rel_dir '.', kept as
314
+ # its own '.' group rather than dropped). Groups are single-key hashes appended
315
+ # in folder-encounter order; the matching one is reused. See ADR-197.
316
+ def add_to_decision_group(doc, rel_dir)
317
+ group_name = rel_dir.split('/').first
318
+ group = @project_data.decision_groups.find { |g| g.key?(group_name) }
319
+ if group.nil?
320
+ @project_data.decision_groups.append({ group_name => [doc] })
321
+ else
322
+ group[group_name].append(doc)
323
+ end
324
+ end
325
+
326
+ # Collect risk records (ADR-215): each first-level subfolder of risks/ is a
327
+ # risk registry; a registry's overview.md is its preface, not a record. Files
328
+ # directly under risks/ belong to no registry and are not collected.
329
+ def parse_risks
330
+ path = @configuration.project_root_directory
331
+ risks_root = "#{path}/risks"
332
+ Dir.glob("#{risks_root}/*/**/*.md").each do |f|
333
+ if File.basename(f).downcase == 'overview.md'
334
+ register_risk_preface(f, risks_root)
335
+ next
336
+ end
337
+
338
+ doc = DocFabric.create_risk_record(f)
339
+ rel_dir = File.dirname(f.sub("#{risks_root}/", ''))
340
+ doc.registry = rel_dir.split('/').first
341
+ doc.html_rel_path = "#{rel_dir}/#{doc.id}.html"
342
+ @project_data.risk_records.append(doc)
343
+ add_to_risk_registry(doc)
344
+ end
345
+ BaseDocument.show_risks_link = risk_registry_names.any?
346
+ ConsoleReporter.count('parsing risk records', @project_data.risk_records.length)
347
+ report_duplicate_risk_ids
348
+ end
349
+
350
+ # The registry names in file-system (parse) order: every first-level risks/
351
+ # folder holding records or a preface. The set that earns the top-menu Risks
352
+ # button (ADR-219) and a registry page (ADR-216).
353
+ def risk_registry_names
354
+ (@project_data.risk_registries.map { |g| g.keys.first } +
355
+ @project_data.risk_registry_prefaces.keys).uniq
356
+ end
357
+
358
+ # A registry's own overview.md is its preface (ADR-216), parsed like a record
359
+ # for rendering but never collected. An overview.md nested deeper inside a
360
+ # registry is neither a record nor a preface and is skipped entirely.
361
+ def register_risk_preface(file, risks_root)
362
+ rel_dir = File.dirname(file.sub("#{risks_root}/", ''))
363
+ return unless rel_dir.index('/').nil?
364
+
365
+ doc = DocFabric.create_risk_record(file)
366
+ doc.registry = rel_dir
367
+ doc.output_rel_path = "risks/#{rel_dir}/overview.html"
368
+ @project_data.risk_registry_prefaces[rel_dir] = doc
369
+ end
370
+
371
+ # Add a risk record to its registry, keyed on the first-level folder under
372
+ # risks/. Registries are single-key hashes appended in folder-encounter order,
373
+ # mirroring add_to_decision_group.
374
+ def add_to_risk_registry(doc)
375
+ registry = @project_data.risk_registries.find { |g| g.key?(doc.registry) }
376
+ if registry.nil?
377
+ @project_data.risk_registries.append({ doc.registry => [doc] })
378
+ else
379
+ registry[doc.registry].append(doc)
380
+ end
381
+ end
382
+
383
+ # Two risk records sharing one id would collide in the project-wide link
384
+ # space; each registry is expected to use its own letter prefix (ADR-215).
385
+ # Reported as a non-failing warning, like broken links.
386
+ def report_duplicate_risk_ids
387
+ duplicates = @project_data.risk_records.group_by(&:id).select { |_id, records| records.length > 1 }
388
+ return if duplicates.empty?
389
+
390
+ ConsoleReporter.warn('duplicated risk ids', duplicates.length)
391
+ duplicates.each do |id, records|
392
+ files = records.map { |r| r.path.sub("#{@configuration.project_root_directory}/", '') }.join(', ')
393
+ puts ConsoleReporter.warn_detail(" #{id}: #{files}")
394
+ end
395
+ end
396
+
185
397
  def parse_test_run(test_run)
186
398
  path = @configuration.project_root_directory
187
399
  Dir.glob("#{path}/tests/runs/#{test_run}/**/*.md").each do |f|
@@ -190,7 +402,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
190
402
  end
191
403
  end
192
404
 
193
- def link_all_specifications # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
405
+ def link_all_specifications
194
406
  comb_list = @project_data.specifications.combination(2)
195
407
  comb_list.each do |c|
196
408
  link_two_specifications(c[0], c[1])
@@ -209,7 +421,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
209
421
  ConsoleReporter.count('traceability matrices', @project_data.traceability_matrices.length)
210
422
  end
211
423
 
212
- def link_all_protocols # rubocop:disable Metrics/MethodLength
424
+ def link_all_protocols
213
425
  @project_data.protocols.each do |p|
214
426
  @project_data.specifications.each do |s|
215
427
  if p.up_link_docs.key?(s.id.to_s)
@@ -239,6 +451,22 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
239
451
  ConsoleReporter.count('decision links', number_of_links)
240
452
  end
241
453
 
454
+ # A risk record's Affected Documents uplinks resolve exactly as a decision
455
+ # record's (ADR-218): the specification paragraph gains the record among its
456
+ # downlinks and a dangling Req-ID lands in the record's wrong_links_hash.
457
+ def link_all_risks
458
+ number_of_links = 0
459
+ @project_data.risk_records.each do |r|
460
+ @project_data.specifications.each do |s|
461
+ next unless r.up_link_docs.key?(s.id.to_s)
462
+
463
+ DocLinker.link_decision_to_spec(r, s)
464
+ number_of_links += 1
465
+ end
466
+ end
467
+ ConsoleReporter.count('risk links', number_of_links)
468
+ end
469
+
242
470
  def link_all_source_files
243
471
  return unless DocLinker.link_all_source_files(@project_data)
244
472
 
@@ -250,7 +478,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
250
478
  ConsoleReporter.count('implementation matrices', @project_data.implementation_matrices.length)
251
479
  end
252
480
 
253
- def check_wrong_specification_referenced # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
481
+ def check_wrong_specification_referenced
254
482
  available_specification_ids = {}
255
483
 
256
484
  @project_data.specifications.each do |s|
@@ -280,7 +508,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
280
508
  end
281
509
  end
282
510
 
283
- def link_two_specifications(doc_a, doc_b) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
511
+ def link_two_specifications(doc_a, doc_b)
284
512
  if doc_b.up_link_docs.key?(doc_a.id.to_s)
285
513
  top_document = doc_a
286
514
  bottom_document = doc_b
@@ -383,7 +611,17 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
383
611
  doc.to_html("#{path}/build/decisions/")
384
612
  end
385
613
 
386
- def render_all_decisions # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
614
+ def render_critical_chain_page
615
+ return if @project_data.decisions.empty?
616
+
617
+ path = @configuration.project_root_directory
618
+ FileUtils.mkdir_p("#{path}/build/decisions")
619
+
620
+ doc = DocFabric.create_critical_chain_page(@project)
621
+ doc.to_html("#{path}/build/decisions/")
622
+ end
623
+
624
+ def render_all_decisions
387
625
  return if @project_data.decisions.empty?
388
626
 
389
627
  build_decisions_root = "#{@configuration.project_root_directory}/build/decisions"
@@ -398,6 +636,66 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
398
636
  end
399
637
  end
400
638
 
639
+ # Each registry renders to build/risks/<registry>/overview.html (ADR-216):
640
+ # the rendered preface first, then the register table of the registry's
641
+ # records, with the columns configured under the risks: root of project.yml
642
+ # (implicit columns plus Status when unconfigured). A registry holding only
643
+ # an overview.md still renders, with an empty table. Runs after
644
+ # render_all_risk_records so every record carries its rendering paths.
645
+ def render_risk_registry_pages
646
+ registry_names = risk_registry_names
647
+ return if registry_names.empty?
648
+
649
+ path = @configuration.project_root_directory
650
+ registry_names.each do |name|
651
+ records = @project_data.risk_registries.find { |g| g.key?(name) }&.fetch(name) || []
652
+ preface = @project_data.risk_registry_prefaces[name]
653
+ if preface
654
+ preface.root_prefix = '../../'
655
+ preface.specifications_path = "./#{preface.root_prefix}specifications/"
656
+ end
657
+ doc = DocFabric.create_risk_registry_page(name, records, preface, @configuration.get_risk_columns(name),
658
+ @configuration.get_risk_rpn_groups(name))
659
+ out_dir = "#{path}/build/risks/#{name}"
660
+ FileUtils.mkdir_p(out_dir)
661
+ doc.to_html("#{out_dir}/")
662
+ end
663
+ end
664
+
665
+ # The all-registries summary page (ADR-219): build/risks/overview.html, one
666
+ # row per registry with the total, open, and leading-group RPN aggregates.
667
+ # Rendered whenever the project has at least one registry — the same
668
+ # condition that emits the top-menu Risks button.
669
+ def render_risks_overview
670
+ registry_names = risk_registry_names
671
+ return if registry_names.empty?
672
+
673
+ registries = registry_names.map do |name|
674
+ [name, @project_data.risk_registries.find { |g| g.key?(name) }&.fetch(name) || []]
675
+ end
676
+ path = @configuration.project_root_directory
677
+ FileUtils.mkdir_p("#{path}/build/risks")
678
+ doc = DocFabric.create_risks_overview(registries, @configuration, @project_data.risk_registry_prefaces)
679
+ doc.to_html("#{path}/build/risks/")
680
+ end
681
+
682
+ # Each risk record renders to its own page under build/risks/<registry>/,
683
+ # with the navigation pane, exactly as decision records do (ADR-215).
684
+ def render_all_risk_records
685
+ return if @project_data.risk_records.empty?
686
+
687
+ build_risks_root = "#{@configuration.project_root_directory}/build/risks"
688
+ @project_data.risk_records.each do |doc|
689
+ out_dir_rel = File.dirname(doc.html_rel_path)
690
+ out_dir = "#{build_risks_root}/#{out_dir_rel}"
691
+ FileUtils.mkdir_p(out_dir)
692
+ depth = 1 + out_dir_rel.split('/').size
693
+ doc.root_prefix = '../' * depth
694
+ doc.specifications_path = "./#{doc.root_prefix}specifications/"
695
+ doc.to_html(NavigationPane.new(doc), "#{out_dir}/")
696
+ end
697
+ end
698
+
401
699
  def create_search_data
402
700
  db = SpecificationsDb.new @project_data.specifications
403
701
  data_path = "#{@configuration.project_root_directory}/build/data"
@@ -1,42 +1,189 @@
1
1
  require 'yaml'
2
+ require 'date'
2
3
 
3
4
  class ProjectConfiguration
4
- attr_accessor :project_root_directory, :parameters
5
+ DEFAULT_WIP_LIMIT = 2
6
+ DEFAULT_BUFFER_RATIO = 0.5
7
+ DEFAULT_HOURS_PER_DAY = 8
5
8
 
6
- def initialize(path)
7
- @project_root_directory = File.expand_path(path)
8
- @parameters = {}
9
- load_project_file
10
- end
9
+ attr_accessor :project_root_directory, :parameters
11
10
 
12
- def load_project_file
13
- @parameters = YAML.load_file(@project_root_directory + '/project.yml')
14
- rescue Psych::SyntaxError => e
15
- puts "YAML syntax error: #{e.message}"
16
- rescue Errno::ENOENT
17
- puts 'Project file not found: project.yml'
18
- end
11
+ def initialize(path)
12
+ @project_root_directory = File.expand_path(path)
13
+ @parameters = {}
14
+ load_project_file
15
+ end
19
16
 
20
- def get_design_inputs
21
- if (@parameters.key? 'specifications') and (@parameters['specifications'].key? 'input')
22
- return @parameters['specifications']['input']
23
- end
17
+ def load_project_file
18
+ @parameters = YAML.load_file(@project_root_directory + '/project.yml')
19
+ rescue Psych::SyntaxError => e
20
+ puts "YAML syntax error: #{e.message}"
21
+ rescue Errno::ENOENT
22
+ puts 'Project file not found: project.yml'
23
+ end
24
24
 
25
- []
26
- end
25
+ def get_design_inputs
26
+ return @parameters['specifications']['input'] if (@parameters.key? 'specifications') and (@parameters['specifications'].key? 'input')
27
+
28
+ []
29
+ end
30
+
31
+ def get_repositories
32
+ return @parameters['repositories'] if @parameters.key? 'repositories'
33
+
34
+ []
35
+ end
36
+
37
+ def get_wip_limit
38
+ return DEFAULT_WIP_LIMIT unless @parameters.is_a?(Hash)
39
+
40
+ planning = @parameters['planning']
41
+ return DEFAULT_WIP_LIMIT unless planning.is_a?(Hash)
42
+
43
+ value = planning['wip_limit']
44
+ return DEFAULT_WIP_LIMIT unless value.is_a?(Integer) && value.positive?
45
+
46
+ value
47
+ end
27
48
 
28
- def get_repositories
29
- return @parameters['repositories'] if @parameters.key? 'repositories'
49
+ # The CCPM project-buffer ratio (ADR-195): a fraction in (0, 1] cutting the
50
+ # aggregated chain safety. Absent or out-of-range falls back to the 0.5 default.
51
+ def get_buffer_ratio
52
+ return DEFAULT_BUFFER_RATIO unless @parameters.is_a?(Hash)
30
53
 
31
- []
54
+ planning = @parameters['planning']
55
+ return DEFAULT_BUFFER_RATIO unless planning.is_a?(Hash)
56
+
57
+ value = planning['buffer_ratio']
58
+ return DEFAULT_BUFFER_RATIO unless value.is_a?(Numeric) && value.positive? && value <= 1
59
+
60
+ value
61
+ end
62
+
63
+ # The calendar anchor for working day 1 (ADR-205), a Date parsed from a
64
+ # DD-MM-YYYY planning.start_date. Absent or unparseable falls back to today,
65
+ # so an unconfigured project still renders (a moving anchor).
66
+ def get_start_date
67
+ date = parse_planning_date(planning_value('start_date'))
68
+ date || Date.today
69
+ end
70
+
71
+ # Per-group planning start dates (ADR-211): a map from a decision group's
72
+ # first-level folder name (under decisions/) to its start Date, read from
73
+ # planning.groups as DD-MM-YYYY entries. Non-string or unparseable values are
74
+ # dropped; empty when unset. A group absent from the map sequences after the
75
+ # previous group rather than carrying a declared start.
76
+ def get_group_start_dates
77
+ value = planning_value('groups')
78
+ return {} unless value.is_a?(Hash)
79
+
80
+ value.each_with_object({}) do |(name, raw), acc|
81
+ date = parse_planning_date(raw)
82
+ acc[name.to_s] = date if date
32
83
  end
84
+ end
85
+
86
+ # The non-working holiday dates (ADR-205) from planning.holidays, each a
87
+ # DD-MM-YYYY entry; unparseable entries are dropped. Empty when unset.
88
+ def get_holidays
89
+ value = planning_value('holidays')
90
+ return [] unless value.is_a?(Array)
91
+
92
+ value.filter_map { |entry| parse_planning_date(entry) }
93
+ end
94
+
95
+ # Working hours per day (ADR-196): converts logged effort hours into the
96
+ # working-day unit the estimates use. Absent or non-positive falls back to 8.
97
+ def get_hours_per_day
98
+ return DEFAULT_HOURS_PER_DAY unless @parameters.is_a?(Hash)
99
+
100
+ planning = @parameters['planning']
101
+ return DEFAULT_HOURS_PER_DAY unless planning.is_a?(Hash)
33
102
 
34
- def is_spec_db_shall_be_created
35
- if @parameters.key? 'output'
36
- @parameters['output'].each do |p|
37
- return true if p == 'specifications_db'
38
- end
39
- end
40
- false
103
+ value = planning['hours_per_day']
104
+ return DEFAULT_HOURS_PER_DAY unless value.is_a?(Numeric) && value.positive?
105
+
106
+ value
107
+ end
108
+
109
+ # The ordered register-column list for a risk registry folder (ADR-216),
110
+ # read from the risks: root — a list of { folder:, columns: } entries.
111
+ # nil when the registry carries no configuration; such a registry renders
112
+ # the implicit columns plus Status only.
113
+ def get_risk_columns(folder)
114
+ entry = risk_entry(folder)
115
+ return nil unless entry.is_a?(Hash) && entry['columns'].is_a?(Array)
116
+
117
+ entry['columns'].map(&:to_s)
118
+ end
119
+
120
+ # The named RPN groups of a risk registry folder (ADR-217), from the rpn:
121
+ # list of its risks: entry, in configured order:
122
+ # [{ name:, inputs: [..], acceptable:, unacceptable: }, ...]. Groups without
123
+ # a name or a non-empty inputs list are dropped; a threshold bound that is
124
+ # absent or not numeric is nil. Empty when the registry declares no groups.
125
+ def get_risk_rpn_groups(folder)
126
+ entry = risk_entry(folder)
127
+ return [] unless entry.is_a?(Hash) && entry['rpn'].is_a?(Array)
128
+
129
+ entry['rpn'].filter_map { |raw| risk_rpn_group(raw) }
130
+ end
131
+
132
+ def is_spec_db_shall_be_created
133
+ if @parameters.key? 'output'
134
+ @parameters['output'].each do |p|
135
+ return true if p == 'specifications_db'
136
+ end
41
137
  end
138
+ false
139
+ end
140
+
141
+ # The risks: entry configuring a registry folder, or nil when absent.
142
+ def risk_entry(folder)
143
+ return nil unless @parameters.is_a?(Hash)
144
+
145
+ entries = @parameters['risks']
146
+ return nil unless entries.is_a?(Array)
147
+
148
+ entries.find { |e| e.is_a?(Hash) && e['folder'].to_s == folder }
149
+ end
150
+
151
+ def risk_rpn_group(raw)
152
+ return nil unless raw.is_a?(Hash)
153
+
154
+ name = raw['name'].to_s
155
+ inputs = raw['inputs']
156
+ return nil if name.empty? || !inputs.is_a?(Array) || inputs.empty?
157
+
158
+ thresholds = raw['thresholds'].is_a?(Hash) ? raw['thresholds'] : {}
159
+ { name: name, inputs: inputs.map(&:to_s),
160
+ acceptable: numeric_threshold(thresholds['acceptable']),
161
+ unacceptable: numeric_threshold(thresholds['unacceptable']) }
162
+ end
163
+
164
+ def numeric_threshold(value)
165
+ value.is_a?(Numeric) ? value : nil
166
+ end
167
+
168
+ # A value under the planning: key, or nil when planning is absent.
169
+ def planning_value(key)
170
+ return nil unless @parameters.is_a?(Hash)
171
+
172
+ planning = @parameters['planning']
173
+ planning.is_a?(Hash) ? planning[key] : nil
174
+ end
175
+
176
+ # Parse a planning date that may already be a Date (YAML ISO form) or a
177
+ # DD-MM-YYYY string; nil when neither.
178
+ def parse_planning_date(value)
179
+ return value if value.is_a?(Date)
180
+ return nil unless value.is_a?(String)
181
+
182
+ match = /\A(\d{2})-(\d{2})-(\d{4})\z/.match(value.strip)
183
+ return nil unless match
184
+
185
+ Date.new(match[3].to_i, match[2].to_i, match[1].to_i)
186
+ rescue ArgumentError
187
+ nil
188
+ end
42
189
  end