Almirah 0.4.3 → 0.4.5

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.
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require_relative 'doc_fabric'
5
- require_relative 'doc_items/work_item'
6
5
  require_relative 'navigation_pane'
7
6
  require_relative 'doc_types/traceability'
8
7
  require_relative 'doc_types/index'
@@ -45,13 +44,14 @@ class Project
45
44
  parse_all_protocols
46
45
  parse_all_source_files
47
46
  parse_decisions
47
+ parse_risks
48
48
  link_all_specifications
49
49
  link_all_protocols
50
50
  link_all_source_files
51
51
  link_all_decisions
52
+ link_all_risks
52
53
  check_wrong_specification_referenced
53
54
  build_link_registry
54
- link_work_items
55
55
  create_index
56
56
  render_all_specifications(@project_data.specifications)
57
57
  render_all_specifications(@project_data.traceability_matrices)
@@ -60,12 +60,13 @@ class Project
60
60
  render_all_source_files
61
61
  render_all_specifications(@project_data.implementation_matrices) # intentionally after source file rendering
62
62
  render_decisions_overview
63
- render_critical_chain_page
64
63
  render_all_decisions
64
+ render_all_risk_records
65
+ render_risk_registry_pages
66
+ render_risks_overview
65
67
  render_index
66
68
  create_search_data
67
69
  report_broken_links
68
- report_kit_violations
69
70
  report_rendered
70
71
  end
71
72
 
@@ -74,13 +75,14 @@ class Project
74
75
  parse_test_run test_run
75
76
  parse_all_source_files
76
77
  parse_decisions
78
+ parse_risks
77
79
  link_all_specifications
78
80
  link_all_protocols
79
81
  link_all_source_files
80
82
  link_all_decisions
83
+ link_all_risks
81
84
  check_wrong_specification_referenced
82
85
  build_link_registry
83
- link_work_items
84
86
  create_index
85
87
  render_all_specifications(@project_data.specifications)
86
88
  render_all_specifications(@project_data.traceability_matrices)
@@ -89,12 +91,13 @@ class Project
89
91
  render_all_source_files
90
92
  render_all_specifications(@project_data.implementation_matrices) # intentionally after source file rendering
91
93
  render_decisions_overview
92
- render_critical_chain_page
93
94
  render_all_decisions
95
+ render_all_risk_records
96
+ render_risk_registry_pages
97
+ render_risks_overview
94
98
  render_index
95
99
  create_search_data
96
100
  report_broken_links
97
- report_kit_violations
98
101
  report_rendered
99
102
  end
100
103
 
@@ -114,111 +117,6 @@ class Project
114
117
  broken.each { |b| puts ConsoleReporter.warn_detail(" #{b[:document] || '?'}: #{b[:target]}") }
115
118
  end
116
119
 
117
- # Builds the per-row WorkItem dependency network (ADR-194): registers every
118
- # Scope-row work item, fills the intra-record step-order edges, then resolves
119
- # each Depends On reference globally (LinkRegistry) to the activity-type-aligned
120
- # work item of the target record, tagging each cross-record edge in-group or
121
- # cross-group against the decision_groups boundary (ADR-197). Unresolved
122
- # references are collected for report_kit_violations. Runs after
123
- # build_link_registry (so every record resolves) and before rendering (so the
124
- # overview Kit column is ready).
125
- def link_work_items
126
- @kit_unresolved = []
127
- @project_data.decisions.each do |d|
128
- d.scope_work_items.each { |wi| @project_data.work_items[wi.id] = wi }
129
- end
130
- link_intra_record_steps
131
- link_cross_record_dependencies
132
- end
133
-
134
- # Each row's lower-numbered same-record steps are its predecessors; equal step
135
- # numbers are concurrent (no edge). These edges are in-group by definition.
136
- def link_intra_record_steps
137
- @project_data.decisions.each do |d|
138
- items = d.scope_work_items
139
- items.each do |wi|
140
- items.each do |other|
141
- next if other.equal?(wi) || other.step >= wi.step
142
-
143
- wi.add_predecessor(other, cross_group: false)
144
- other.add_successor(wi)
145
- end
146
- end
147
- end
148
- end
149
-
150
- def link_cross_record_dependencies
151
- @project_data.decisions.each do |d|
152
- d.scope_work_items.each do |wi|
153
- wi.depends_on_refs.each { |ref| link_dependency(d, wi, ref) }
154
- end
155
- end
156
- end
157
-
158
- def link_dependency(record, work_item, ref)
159
- target = @project_data.link_registry.find_by_id(ref)
160
- unless target.is_a?(Decision)
161
- @kit_unresolved << { record: record.id, target: ref }
162
- return
163
- end
164
- prereq = aligned_work_item(target, work_item.activity)
165
- return if prereq.nil? || prereq.equal?(work_item)
166
-
167
- cross = decision_group_name(record) != decision_group_name(target)
168
- work_item.add_predecessor(prereq, cross_group: cross)
169
- prereq.add_successor(work_item)
170
- anchor = target.scope_table&.step_column? ? prereq.row_anchor : nil
171
- work_item.add_resolved_dependency(ref, target, anchor, prereq.id)
172
- end
173
-
174
- # The target record's work item whose activity (Item) matches `activity`,
175
- # falling back to the nearest earlier activity by canonical phase order, then
176
- # (when the target has only later activities) to its earliest row. nil only
177
- # when the target has no Scope rows.
178
- def aligned_work_item(target, activity)
179
- items = target.scope_work_items
180
- return nil if items.empty?
181
-
182
- exact = items.select { |t| t.activity == activity }.min_by(&:step)
183
- return exact if exact
184
-
185
- rank = WorkItem::ACTIVITY_ORDER.index(activity) || WorkItem::ACTIVITY_ORDER.length
186
- earlier = items.select { |t| t.activity_rank <= rank }
187
- return items.min_by { |t| [t.activity_rank, t.step] } if earlier.empty?
188
-
189
- earlier.min_by { |t| [-t.activity_rank, t.step] }
190
- end
191
-
192
- # The planning-group name (first-level decisions/ folder) a record belongs to,
193
- # read from the decision_groups collection (ADR-197).
194
- def decision_group_name(doc)
195
- group = @project_data.decision_groups.find { |g| g.values.first.include?(doc) }
196
- group&.keys&.first
197
- end
198
-
199
- # Reports the two kit gates and unresolved Depends On references (ADR-194), all
200
- # as non-failing console warnings alongside report_broken_links.
201
- def report_kit_violations
202
- @kit_unresolved ||= []
203
- phase = @project_data.work_items.each_value.select(&:phase_order_violation?)
204
- cross = @project_data.work_items.each_value.select(&:cross_record_violation?)
205
- total = phase.length + cross.length + @kit_unresolved.length
206
- return if total.zero?
207
-
208
- ConsoleReporter.warn('kit violations', total)
209
- phase.each do |wi|
210
- blocking = wi.intra_record_predecessors.reject(&:done?).map(&:id).join(', ')
211
- puts ConsoleReporter.warn_detail(" phase order: #{wi.id} started before #{blocking}")
212
- end
213
- cross.each do |wi|
214
- blocking = wi.cross_record_predecessors.reject(&:done?).map(&:id).join(', ')
215
- puts ConsoleReporter.warn_detail(" not kitted: #{wi.id} needs #{blocking}")
216
- end
217
- @kit_unresolved.each do |u|
218
- puts ConsoleReporter.warn_detail(" unresolved Depends On: #{u[:record]} -> #{u[:target]}")
219
- end
220
- end
221
-
222
120
  # Assigns each document its generated output path (relative to the build root)
223
121
  # and registers it for cross-document link resolution (ADR-186). Runs after all
224
122
  # documents are parsed and before any rendering, so link targets are known.
@@ -238,6 +136,10 @@ class Project
238
136
  d.output_rel_path = "decisions/#{d.html_rel_path}"
239
137
  reg.register(d)
240
138
  end
139
+ @project_data.risk_records.each do |d|
140
+ d.output_rel_path = "risks/#{d.html_rel_path}"
141
+ reg.register(d)
142
+ end
241
143
  @project_data.source_files.each do |d|
242
144
  rel = d.path.sub("#{d.root_path}/", '')
243
145
  d.output_rel_path = "source_files/#{d.repository}/#{rel}.html"
@@ -309,6 +211,77 @@ class Project
309
211
  end
310
212
  end
311
213
 
214
+ # Collect risk records (ADR-215): each first-level subfolder of risks/ is a
215
+ # risk registry; a registry's overview.md is its preface, not a record. Files
216
+ # directly under risks/ belong to no registry and are not collected.
217
+ def parse_risks
218
+ path = @configuration.project_root_directory
219
+ risks_root = "#{path}/risks"
220
+ Dir.glob("#{risks_root}/*/**/*.md").each do |f|
221
+ if File.basename(f).downcase == 'overview.md'
222
+ register_risk_preface(f, risks_root)
223
+ next
224
+ end
225
+
226
+ doc = DocFabric.create_risk_record(f)
227
+ rel_dir = File.dirname(f.sub("#{risks_root}/", ''))
228
+ doc.registry = rel_dir.split('/').first
229
+ doc.html_rel_path = "#{rel_dir}/#{doc.id}.html"
230
+ @project_data.risk_records.append(doc)
231
+ add_to_risk_registry(doc)
232
+ end
233
+ BaseDocument.show_risks_link = risk_registry_names.any?
234
+ ConsoleReporter.count('parsing risk records', @project_data.risk_records.length)
235
+ report_duplicate_risk_ids
236
+ end
237
+
238
+ # The registry names in file-system (parse) order: every first-level risks/
239
+ # folder holding records or a preface. The set that earns the top-menu Risks
240
+ # button (ADR-219) and a registry page (ADR-216).
241
+ def risk_registry_names
242
+ (@project_data.risk_registries.map { |g| g.keys.first } +
243
+ @project_data.risk_registry_prefaces.keys).uniq
244
+ end
245
+
246
+ # A registry's own overview.md is its preface (ADR-216), parsed like a record
247
+ # for rendering but never collected. An overview.md nested deeper inside a
248
+ # registry is neither a record nor a preface and is skipped entirely.
249
+ def register_risk_preface(file, risks_root)
250
+ rel_dir = File.dirname(file.sub("#{risks_root}/", ''))
251
+ return unless rel_dir.index('/').nil?
252
+
253
+ doc = DocFabric.create_risk_record(file)
254
+ doc.registry = rel_dir
255
+ doc.output_rel_path = "risks/#{rel_dir}/overview.html"
256
+ @project_data.risk_registry_prefaces[rel_dir] = doc
257
+ end
258
+
259
+ # Add a risk record to its registry, keyed on the first-level folder under
260
+ # risks/. Registries are single-key hashes appended in folder-encounter order,
261
+ # mirroring add_to_decision_group.
262
+ def add_to_risk_registry(doc)
263
+ registry = @project_data.risk_registries.find { |g| g.key?(doc.registry) }
264
+ if registry.nil?
265
+ @project_data.risk_registries.append({ doc.registry => [doc] })
266
+ else
267
+ registry[doc.registry].append(doc)
268
+ end
269
+ end
270
+
271
+ # Two risk records sharing one id would collide in the project-wide link
272
+ # space; each registry is expected to use its own letter prefix (ADR-215).
273
+ # Reported as a non-failing warning, like broken links.
274
+ def report_duplicate_risk_ids
275
+ duplicates = @project_data.risk_records.group_by(&:id).select { |_id, records| records.length > 1 }
276
+ return if duplicates.empty?
277
+
278
+ ConsoleReporter.warn('duplicated risk ids', duplicates.length)
279
+ duplicates.each do |id, records|
280
+ files = records.map { |r| r.path.sub("#{@configuration.project_root_directory}/", '') }.join(', ')
281
+ puts ConsoleReporter.warn_detail(" #{id}: #{files}")
282
+ end
283
+ end
284
+
312
285
  def parse_test_run(test_run)
313
286
  path = @configuration.project_root_directory
314
287
  Dir.glob("#{path}/tests/runs/#{test_run}/**/*.md").each do |f|
@@ -366,6 +339,22 @@ class Project
366
339
  ConsoleReporter.count('decision links', number_of_links)
367
340
  end
368
341
 
342
+ # A risk record's Affected Documents uplinks resolve exactly as a decision
343
+ # record's (ADR-218): the specification paragraph gains the record among its
344
+ # downlinks and a dangling Req-ID lands in the record's wrong_links_hash.
345
+ def link_all_risks
346
+ number_of_links = 0
347
+ @project_data.risk_records.each do |r|
348
+ @project_data.specifications.each do |s|
349
+ next unless r.up_link_docs.key?(s.id.to_s)
350
+
351
+ DocLinker.link_decision_to_spec(r, s)
352
+ number_of_links += 1
353
+ end
354
+ end
355
+ ConsoleReporter.count('risk links', number_of_links)
356
+ end
357
+
369
358
  def link_all_source_files
370
359
  return unless DocLinker.link_all_source_files(@project_data)
371
360
 
@@ -510,16 +499,6 @@ class Project
510
499
  doc.to_html("#{path}/build/decisions/")
511
500
  end
512
501
 
513
- def render_critical_chain_page
514
- return if @project_data.decisions.empty?
515
-
516
- path = @configuration.project_root_directory
517
- FileUtils.mkdir_p("#{path}/build/decisions")
518
-
519
- doc = DocFabric.create_critical_chain_page(@project)
520
- doc.to_html("#{path}/build/decisions/")
521
- end
522
-
523
502
  def render_all_decisions
524
503
  return if @project_data.decisions.empty?
525
504
 
@@ -535,6 +514,66 @@ class Project
535
514
  end
536
515
  end
537
516
 
517
+ # Each registry renders to build/risks/<registry>/overview.html (ADR-216):
518
+ # the rendered preface first, then the register table of the registry's
519
+ # records, with the columns configured under the risks: root of project.yml
520
+ # (implicit columns plus Status when unconfigured). A registry holding only
521
+ # an overview.md still renders, with an empty table. Runs after
522
+ # render_all_risk_records so every record carries its rendering paths.
523
+ def render_risk_registry_pages
524
+ registry_names = risk_registry_names
525
+ return if registry_names.empty?
526
+
527
+ path = @configuration.project_root_directory
528
+ registry_names.each do |name|
529
+ records = @project_data.risk_registries.find { |g| g.key?(name) }&.fetch(name) || []
530
+ preface = @project_data.risk_registry_prefaces[name]
531
+ if preface
532
+ preface.root_prefix = '../../'
533
+ preface.specifications_path = "./#{preface.root_prefix}specifications/"
534
+ end
535
+ doc = DocFabric.create_risk_registry_page(name, records, preface, @configuration.get_risk_columns(name),
536
+ @configuration.get_risk_rpn_groups(name))
537
+ out_dir = "#{path}/build/risks/#{name}"
538
+ FileUtils.mkdir_p(out_dir)
539
+ doc.to_html("#{out_dir}/")
540
+ end
541
+ end
542
+
543
+ # The all-registries summary page (ADR-219): build/risks/overview.html, one
544
+ # row per registry with the total, open, and leading-group RPN aggregates.
545
+ # Rendered whenever the project has at least one registry — the same
546
+ # condition that emits the top-menu Risks button.
547
+ def render_risks_overview
548
+ registry_names = risk_registry_names
549
+ return if registry_names.empty?
550
+
551
+ registries = registry_names.map do |name|
552
+ [name, @project_data.risk_registries.find { |g| g.key?(name) }&.fetch(name) || []]
553
+ end
554
+ path = @configuration.project_root_directory
555
+ FileUtils.mkdir_p("#{path}/build/risks")
556
+ doc = DocFabric.create_risks_overview(registries, @configuration, @project_data.risk_registry_prefaces)
557
+ doc.to_html("#{path}/build/risks/")
558
+ end
559
+
560
+ # Each risk record renders to its own page under build/risks/<registry>/,
561
+ # with the navigation pane, exactly as decision records do (ADR-215).
562
+ def render_all_risk_records
563
+ return if @project_data.risk_records.empty?
564
+
565
+ build_risks_root = "#{@configuration.project_root_directory}/build/risks"
566
+ @project_data.risk_records.each do |doc|
567
+ out_dir_rel = File.dirname(doc.html_rel_path)
568
+ out_dir = "#{build_risks_root}/#{out_dir_rel}"
569
+ FileUtils.mkdir_p(out_dir)
570
+ depth = 1 + out_dir_rel.split('/').size
571
+ doc.root_prefix = '../' * depth
572
+ doc.specifications_path = "./#{doc.root_prefix}specifications/"
573
+ doc.to_html(NavigationPane.new(doc), "#{out_dir}/")
574
+ end
575
+ end
576
+
538
577
  def create_search_data
539
578
  db = SpecificationsDb.new @project_data.specifications
540
579
  data_path = "#{@configuration.project_root_directory}/build/data"
@@ -1,11 +1,6 @@
1
1
  require 'yaml'
2
- require 'date'
3
2
 
4
3
  class ProjectConfiguration
5
- DEFAULT_WIP_LIMIT = 2
6
- DEFAULT_BUFFER_RATIO = 0.5
7
- DEFAULT_HOURS_PER_DAY = 8
8
-
9
4
  attr_accessor :project_root_directory, :parameters
10
5
 
11
6
  def initialize(path)
@@ -34,76 +29,27 @@ class ProjectConfiguration
34
29
  []
35
30
  end
36
31
 
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?
32
+ # The ordered register-column list for a risk registry folder (ADR-216),
33
+ # read from the risks: root — a list of { folder:, columns: } entries.
34
+ # nil when the registry carries no configuration; such a registry renders
35
+ # the implicit columns plus Status only.
36
+ def get_risk_columns(folder)
37
+ entry = risk_entry(folder)
38
+ return nil unless entry.is_a?(Hash) && entry['columns'].is_a?(Array)
45
39
 
46
- value
40
+ entry['columns'].map(&:to_s)
47
41
  end
48
42
 
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)
43
+ # The named RPN groups of a risk registry folder (ADR-217), from the rpn:
44
+ # list of its risks: entry, in configured order:
45
+ # [{ name:, inputs: [..], acceptable:, unacceptable: }, ...]. Groups without
46
+ # a name or a non-empty inputs list are dropped; a threshold bound that is
47
+ # absent or not numeric is nil. Empty when the registry declares no groups.
48
+ def get_risk_rpn_groups(folder)
49
+ entry = risk_entry(folder)
50
+ return [] unless entry.is_a?(Hash) && entry['rpn'].is_a?(Array)
53
51
 
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
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)
102
-
103
- value = planning['hours_per_day']
104
- return DEFAULT_HOURS_PER_DAY unless value.is_a?(Numeric) && value.positive?
105
-
106
- value
52
+ entry['rpn'].filter_map { |raw| risk_rpn_group(raw) }
107
53
  end
108
54
 
109
55
  def is_spec_db_shall_be_created
@@ -115,25 +61,30 @@ class ProjectConfiguration
115
61
  false
116
62
  end
117
63
 
118
- # A value under the planning: key, or nil when planning is absent.
119
- def planning_value(key)
64
+ # The risks: entry configuring a registry folder, or nil when absent.
65
+ def risk_entry(folder)
120
66
  return nil unless @parameters.is_a?(Hash)
121
67
 
122
- planning = @parameters['planning']
123
- planning.is_a?(Hash) ? planning[key] : nil
68
+ entries = @parameters['risks']
69
+ return nil unless entries.is_a?(Array)
70
+
71
+ entries.find { |e| e.is_a?(Hash) && e['folder'].to_s == folder }
124
72
  end
125
73
 
126
- # Parse a planning date that may already be a Date (YAML ISO form) or a
127
- # DD-MM-YYYY string; nil when neither.
128
- def parse_planning_date(value)
129
- return value if value.is_a?(Date)
130
- return nil unless value.is_a?(String)
74
+ def risk_rpn_group(raw)
75
+ return nil unless raw.is_a?(Hash)
131
76
 
132
- match = /\A(\d{2})-(\d{2})-(\d{4})\z/.match(value.strip)
133
- return nil unless match
77
+ name = raw['name'].to_s
78
+ inputs = raw['inputs']
79
+ return nil if name.empty? || !inputs.is_a?(Array) || inputs.empty?
80
+
81
+ thresholds = raw['thresholds'].is_a?(Hash) ? raw['thresholds'] : {}
82
+ { name: name, inputs: inputs.map(&:to_s),
83
+ acceptable: numeric_threshold(thresholds['acceptable']),
84
+ unacceptable: numeric_threshold(thresholds['unacceptable']) }
85
+ end
134
86
 
135
- Date.new(match[3].to_i, match[2].to_i, match[1].to_i)
136
- rescue ArgumentError
137
- nil
87
+ def numeric_threshold(value)
88
+ value.is_a?(Numeric) ? value : nil
138
89
  end
139
90
  end