Almirah 0.4.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46e67646a16db801d9dbe0cd2c2873a38e6e35068c5cde855179b9f5beb00089
4
- data.tar.gz: c4953874698c949e6711ebdfa0c84dc0b56d6291674a7127d6ccfbb31802cea4
3
+ metadata.gz: 7b53b0b8a1c024c618e1fff5aac5288312b51b2a06f228527f06183b31f114ac
4
+ data.tar.gz: 41959a8eaba117737d913afd63bda6947301e3a5a84aac96b94030f43f6a4564
5
5
  SHA512:
6
- metadata.gz: 1401a30fbde34dd6c21b77685e3e2506753e7b302d528189e5d3d129eff6f9a15dd169f756be53ea1467dc5805fe36d0ce0678d2a446a866b18e95c66e70de64
7
- data.tar.gz: 1db3dd4230d6b77154758bdbad3802fb121ba23c41a3ee33aeb8ba70ac0be4a7ac0d8e14ea05667869d2625fcc638ca2d900a47270f22ff9bac1077c932173ba
6
+ metadata.gz: 66edfea3c57bb86b6cc5e63de0b63579ec756a12b2704db2c86d2956124e7a9a89fd6e1db86f5481c18840bb4378acbb11d1a5db250d14ccfc7a699c7e0f614e
7
+ data.tar.gz: ddbd78d3135822cf967a6fac7c2ae87986dbf79aa537b403dfc15c41ffbc36c7594c9460cb086b067d70b7492aee4f0bc81c48b40b07caf02ea70b22fffd915b
@@ -6,6 +6,9 @@ require_relative 'doc_types/coverage'
6
6
  require_relative 'doc_types/implementation'
7
7
  require_relative 'doc_types/traceability'
8
8
  require_relative 'doc_types/decision'
9
+ require_relative 'doc_types/risk_record'
10
+ require_relative 'doc_types/risk_registry_page'
11
+ require_relative 'doc_types/risks_overview'
9
12
  require_relative 'doc_types/decisions_overview'
10
13
  require_relative 'doc_types/critical_chain_page'
11
14
  require_relative 'doc_parser'
@@ -73,6 +76,21 @@ class DocFabric
73
76
  doc
74
77
  end
75
78
 
79
+ def self.create_risk_record(path)
80
+ doc = RiskRecord.new path
81
+ DocFabric.parse_document doc
82
+ doc.extract_current_status
83
+ doc
84
+ end
85
+
86
+ def self.create_risk_registry_page(registry, records, preface, columns, rpn_groups = [])
87
+ RiskRegistryPage.new registry, records, preface, columns, rpn_groups
88
+ end
89
+
90
+ def self.create_risks_overview(registries, configuration, prefaces = {})
91
+ RisksOverview.new registries, configuration, prefaces
92
+ end
93
+
76
94
  def self.create_decisions_overview(project)
77
95
  DecisionsOverview.new project
78
96
  end
@@ -114,7 +114,7 @@ class ControlledParagraph < Paragraph
114
114
  if @decision_record_links.length == 1
115
115
  dr_doc = @decision_record_links[0].parent_doc
116
116
  s += "\t\t<td class=\"item_id\">\
117
- <a href=\"./../../decisions/#{dr_doc.html_rel_path}\" \
117
+ <a href=\"./../../#{dr_doc.output_rel_path}\" \
118
118
  class=\"external\" title=\"Decision Record\">#{dr_doc.id.upcase}</a></td>\n"
119
119
  else
120
120
  s += "\t\t<td class=\"item_id\">"
@@ -125,7 +125,7 @@ class ControlledParagraph < Paragraph
125
125
  s += "<div id=\"DRS_#{@id}\" style=\"display: none;\">"
126
126
  @decision_record_links.each do |lnk|
127
127
  dr_doc = lnk.parent_doc
128
- s += "\t\t\t<a href=\"./../../decisions/#{dr_doc.html_rel_path}\" \
128
+ s += "\t\t\t<a href=\"./../../#{dr_doc.output_rel_path}\" \
129
129
  class=\"external\" title=\"Referenced in\">#{dr_doc.id.upcase}</a>\n<br>"
130
130
  end
131
131
  s += '</div>'
@@ -250,13 +250,14 @@ class DocParser
250
250
  if temp_md_table
251
251
  if temp_md_table.is_separator_detected # if there is a separator
252
252
  # A Decision Record's Scope table is parsed into a purpose-built
253
- # ScopeTable (ADR-194); a Protocol, or a Decision inside an
254
- # "Affected Documents" section, into a ControlledTable.
253
+ # ScopeTable (ADR-194); a Protocol, or a Decision or RiskRecord
254
+ # inside an "Affected Documents" section, into a ControlledTable
255
+ # (ADR-218 extends the Decision path to risk records).
255
256
  if doc.instance_of?(Decision) && in_section?(doc, 'Scope') &&
256
257
  temp_md_table.instance_of?(MarkdownTable)
257
258
  temp_md_table = ScopeTable.new(doc, temp_md_table)
258
259
  elsif doc.instance_of?(Protocol) ||
259
- (doc.instance_of?(Decision) && in_section?(doc, 'Affected Documents'))
260
+ (doc.is_a?(Decision) && in_section?(doc, 'Affected Documents'))
260
261
  # check if it is a controlled table
261
262
  tmp = /(.*)\s+>\[(\S*)\]/.match(row)
262
263
  if tmp && (temp_md_table.instance_of? MarkdownTable)
@@ -6,7 +6,7 @@ class BaseDocument
6
6
  attr_accessor :title, :id, :dom, :headings, :output_rel_path
7
7
 
8
8
  class << self
9
- attr_accessor :show_decisions_link
9
+ attr_accessor :show_decisions_link, :show_risks_link
10
10
  end
11
11
 
12
12
  def initialize
@@ -37,7 +37,9 @@ class BaseDocument
37
37
  'overview.html'
38
38
  elsif instance_of? CriticalChainPage
39
39
  'critical-chain.html'
40
- elsif instance_of? Decision
40
+ elsif instance_of?(RiskRegistryPage) || instance_of?(RisksOverview)
41
+ 'overview.html'
42
+ elsif is_a? Decision # RiskRecord included
41
43
  "#{@id}.html"
42
44
  else
43
45
  "#{@id}/#{@id}.html"
@@ -72,6 +74,7 @@ class BaseDocument
72
74
  file.puts decisions_link(rel_to('decisions/overview.html'))
73
75
  file.puts critical_chain_link(rel_to('decisions/critical-chain.html'))
74
76
  end
77
+ file.puts risks_link(rel_to('risks/overview.html')) if BaseDocument.show_risks_link
75
78
  elsif s.include?('{{GEM_VERSION}}')
76
79
  file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
77
80
  else
@@ -91,6 +94,11 @@ class BaseDocument
91
94
  %(<a id="critical_chain_menu_item" href="#{href}">#{icon}&nbsp;Critical Chain</a>)
92
95
  end
93
96
 
97
+ def risks_link(href)
98
+ icon = '<span><i class="fa fa-exclamation-triangle" aria-hidden="true"></i></span>'
99
+ %(<a id="risks_menu_item" href="#{href}">#{icon}&nbsp;Risks</a>)
100
+ end
101
+
94
102
  def index_link(href)
95
103
  icon = '<span><i class="fa fa-info" aria-hidden="true"></i></span>'
96
104
  %(<a id="index_menu_item" href="#{href}">#{icon}&nbsp;Index</a>)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'decision'
4
+ require_relative '../doc_items/controlled_table'
5
+
6
+ # A risk record (ADR-215): one Markdown file per risk, collected from the
7
+ # first-level subfolders of the project's risks/ folder, each subfolder being
8
+ # a risk registry. The record format is the decision-record format — filename
9
+ # letters-digits id, frontmatter title, Status table with a "*" current-state
10
+ # marker — so the type reuses Decision wholesale.
11
+ class RiskRecord < Decision
12
+ # The first-level risks/ subfolder the record was collected from.
13
+ attr_accessor :registry
14
+
15
+ def to_console
16
+ puts "\e[36mRisk Record: #{@id}\e[0m"
17
+ end
18
+
19
+ # The rendered HTML of the record section whose heading text equals
20
+ # `section_name` (ADR-216). Empty when there is no such section — the
21
+ # register renders it as an empty cell.
22
+ def section_html(section_name)
23
+ section_items(section_name).map(&:to_html).join
24
+ end
25
+
26
+ # The numeric value of the named section (ADR-217): its items' plain text
27
+ # parsed as a Float. nil when the section is missing, empty, or not numeric —
28
+ # the RPN cell renders blank rather than computing a broken record as safe.
29
+ def section_numeric(section_name)
30
+ texts = section_items(section_name).filter_map { |i| i.text if i.respond_to?(:text) }
31
+ Float(texts.join(' ').strip, exception: false)
32
+ end
33
+
34
+ # The record's value for an RPN group (ADR-217): the product of its numeric
35
+ # input sections. nil when any input is missing or not numeric — such a
36
+ # record renders a blank cell and is ignored by the summary aggregates.
37
+ def rpn_value(group)
38
+ factors = group[:inputs].map { |input| section_numeric(input) }
39
+ return nil if factors.any?(&:nil?)
40
+
41
+ factors.reduce(:*)
42
+ end
43
+
44
+ # The distinct controlled-paragraph IDs the record's Affected Documents
45
+ # Req-ID column links to (ADR-218), in the section's row order — the
46
+ # IDs-only content of the register cell. Empty when the record carries no
47
+ # Affected Documents section or its rows link nothing.
48
+ def affected_document_ids
49
+ table = section_items('Affected Documents').find { |i| i.is_a?(ControlledTable) }
50
+ return [] if table.nil?
51
+
52
+ table.rows.flat_map { |row| row.up_link_ids || [] }.uniq
53
+ end
54
+
55
+ private
56
+
57
+ # The items between the heading whose text equals `section_name` and the
58
+ # next heading of the same or a higher level; empty when no heading matches.
59
+ def section_items(section_name)
60
+ in_section = false
61
+ section_level = nil
62
+ collected = []
63
+ @items.each do |item|
64
+ if item.is_a?(Heading) && !in_section
65
+ next unless item.text.strip == section_name
66
+
67
+ in_section = true
68
+ section_level = item.level
69
+ elsif in_section
70
+ break if item.is_a?(Heading) && item.level <= section_level
71
+
72
+ collected.append item
73
+ end
74
+ end
75
+ collected
76
+ end
77
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_document'
4
+ require_relative 'rpn_rendering'
5
+ require_relative '../doc_items/heading'
6
+
7
+ # The registry page (ADR-216): build/risks/<registry>/overview.html holding
8
+ # the rendered registry preface (overview.md), when present, followed by the
9
+ # register table — one row per risk record with the implicit linked-ID and
10
+ # Title columns first, then the configured columns filled from each record's
11
+ # section matched by heading text. The Status column is reserved: it is filled
12
+ # from the record's current lifecycle status, never from a section.
13
+ class RiskRegistryPage < BaseDocument
14
+ include RpnRendering
15
+
16
+ STATUS_COLUMN = 'Status'
17
+ # The column rendered as bare linked IDs from the record's Affected
18
+ # Documents section (ADR-218), never as the section's prose.
19
+ AFFECTED_DOCUMENTS_COLUMN = 'Affected Documents'
20
+
21
+ attr_accessor :registry, :records, :preface, :columns, :rpn_groups
22
+
23
+ # `columns` is the configured per-registry list (ADR-216); nil means the
24
+ # registry is not configured and gets the implicit columns plus Status only.
25
+ # `rpn_groups` (ADR-217) appends one computed "<Name> RPN" column per group
26
+ # after the configured columns; empty appends nothing.
27
+ def initialize(registry, records, preface, columns, rpn_groups = [])
28
+ super()
29
+ @registry = registry
30
+ @records = records
31
+ @preface = preface
32
+ @columns = columns.nil? ? [STATUS_COLUMN] : columns
33
+ @rpn_groups = rpn_groups
34
+ @id = 'overview'
35
+ @title = preface_title || "Risk Registry: #{registry}"
36
+ end
37
+
38
+ def to_console
39
+ puts "\e[36mRisk Registry: #{@registry}\e[0m"
40
+ end
41
+
42
+ def to_html(output_file_path)
43
+ html_rows = ['']
44
+ html_rows.concat preface_html
45
+ html_rows.append render_register_table
46
+ save_html_to_file(html_rows, nil, output_file_path)
47
+ end
48
+
49
+ private
50
+
51
+ # The preface frontmatter title names the page; without it the registry does.
52
+ def preface_title
53
+ params = @preface&.frontmatter&.parameters
54
+ params && params['title']
55
+ end
56
+
57
+ # The rendered overview.md items. The parser-injected level-0 title heading
58
+ # is not authored preface content and is skipped.
59
+ def preface_html
60
+ return [] if @preface.nil?
61
+
62
+ @preface.items.reject { |i| i.is_a?(Heading) && i.level.zero? }.map(&:to_html)
63
+ end
64
+
65
+ def render_register_table
66
+ s = "<table class=\"controlled risk_register\">\n"
67
+ s += "\t<thead>\n"
68
+ s += "\t\t<th>#</th>\n"
69
+ s += "\t\t<th>Title</th>\n"
70
+ @columns.each { |c| s += "\t\t<th>#{c}</th>\n" }
71
+ @rpn_groups.each { |g| s += "\t\t<th>#{g[:name]} RPN</th>\n" }
72
+ s += "</thead>\n"
73
+ @records.each { |doc| s += render_record_row(doc) }
74
+ s + "</table>\n"
75
+ end
76
+
77
+ def render_record_row(doc)
78
+ href = "./#{record_href(doc)}"
79
+ s = "\t<tr>\n"
80
+ s += "\t\t<td class=\"item_id\">\n"
81
+ s += "\t\t\t<a name=\"#{doc.id}\" id=\"#{doc.id}\" href=\"#{href}\" title=\"Risk Record ID\">#{doc.id.upcase}</a>"
82
+ s += "\t\t</td>\n"
83
+ s += "\t\t<td class=\"item_text\" style='padding: 5px;'>\
84
+ <a href=\"#{href}\" class=\"external\">#{record_title(doc)}</a></td>\n"
85
+ @columns.each { |c| s += render_column_cell(doc, c) }
86
+ @rpn_groups.each { |g| s += render_rpn_cell(doc, g) }
87
+ s + "\t</tr>\n"
88
+ end
89
+
90
+ # The Title cell (ENH-221): the frontmatter title with the record's own
91
+ # leading "ID:" prefix removed, case-insensitively — the ID column already
92
+ # carries it. A title not starting with the record's ID stays unchanged, and
93
+ # the record page keeps the full title. Display-only, like the uppercased ID:
94
+ # anchors and hrefs keep the canonical lowercase id.
95
+ def record_title(doc)
96
+ doc.title.to_s.sub(/\A#{Regexp.escape(doc.id)}\s*:\s*/i, '')
97
+ end
98
+
99
+ # The record page path relative to the registry page, which sits at the
100
+ # registry root: the record's html_rel_path minus its registry segment.
101
+ def record_href(doc)
102
+ doc.html_rel_path.split('/', 2).last
103
+ end
104
+
105
+ def render_column_cell(doc, column)
106
+ return "\t\t<td class=\"item_status\">#{doc.current_status}</td>\n" if column == STATUS_COLUMN
107
+ return render_affected_documents_cell(doc) if column == AFFECTED_DOCUMENTS_COLUMN
108
+
109
+ "\t\t<td class=\"item_text\">#{doc.section_html(column)}</td>\n"
110
+ end
111
+
112
+ # IDs only (ADR-218): each distinct linked controlled-paragraph ID as a
113
+ # clickable link in row order; a dangling ID renders in the existing
114
+ # broken-link style rather than being dropped. The Proposed Text stays on
115
+ # the record page.
116
+ def render_affected_documents_cell(doc)
117
+ links = doc.affected_document_ids.map { |id| affected_document_link(doc, id) }
118
+ "\t\t<td class=\"item_id\">#{links.join(', ')}</td>\n"
119
+ end
120
+
121
+ def affected_document_link(doc, item_id)
122
+ if doc.wrong_links_hash.key?(item_id)
123
+ %(<span class="broken_link" title="Unresolved reference">#{item_id}</span>)
124
+ else
125
+ spec = /^([a-zA-Z]+)-\d+/.match(item_id)&.[](1)&.downcase
126
+ href = "./../../specifications/#{spec}/#{spec}.html##{item_id}"
127
+ %(<a href="#{href}" class="external" title="Affected document">#{item_id}</a>)
128
+ end
129
+ end
130
+
131
+ # The computed group cell (ADR-217): the record's group value, blank when
132
+ # any input is missing or not numeric, coloured by the group's thresholds
133
+ # when configured.
134
+ def render_rpn_cell(doc, group)
135
+ value = doc.rpn_value(group)
136
+ return "\t\t<td class=\"item_rpn\"></td>\n" if value.nil?
137
+
138
+ classes = ['item_rpn', rpn_threshold_class(value, group)].compact.join(' ')
139
+ "\t\t<td class=\"#{classes}\">#{format_rpn(value)}</td>\n"
140
+ end
141
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_document'
4
+ require_relative 'rpn_rendering'
5
+
6
+ # The all-registries summary page (ADR-219): build/risks/overview.html, the
7
+ # target of the top-menu Risks button. One table row per registry, in
8
+ # file-system order, with the columns Risk Registry (linked to the registry
9
+ # page), Total Risks, Open Risks, Highest RPN and Average RPN — the RPN
10
+ # aggregates computed over the registry's leading RPN group, ignoring records
11
+ # whose group value is blank. The Risk Registry cell shows the registry
12
+ # preface's frontmatter title, falling back to the folder name (ENH-221).
13
+ class RisksOverview < BaseDocument
14
+ include RpnRendering
15
+
16
+ OPEN_EXCLUDED_STATUS = 'Closed'
17
+
18
+ attr_accessor :registries, :configuration, :prefaces
19
+
20
+ # `registries` is the ordered list of [name, records] pairs; `prefaces`
21
+ # maps a registry name to its parsed overview.md, when it has one.
22
+ def initialize(registries, configuration, prefaces = {})
23
+ super()
24
+ @registries = registries
25
+ @configuration = configuration
26
+ @prefaces = prefaces
27
+ @id = 'overview'
28
+ @title = 'Risk Registries'
29
+ end
30
+
31
+ def to_console
32
+ puts "\e[36mRisks Overview: #{@id}\e[0m"
33
+ end
34
+
35
+ def to_html(output_file_path)
36
+ html_rows = ['']
37
+ html_rows.append "<h1>#{@title}</h1>\n"
38
+ html_rows.append render_registries_table
39
+ save_html_to_file(html_rows, nil, output_file_path)
40
+ end
41
+
42
+ private
43
+
44
+ def render_registries_table
45
+ s = "<table class=\"controlled risks_overview\">\n"
46
+ s += "\t<thead>\n"
47
+ s += "\t\t<th>Risk Registry</th>\n"
48
+ s += "\t\t<th>Total Risks</th>\n"
49
+ s += "\t\t<th>Open Risks</th>\n"
50
+ s += "\t\t<th>Highest RPN</th>\n"
51
+ s += "\t\t<th>Average RPN</th>\n"
52
+ s += "</thead>\n"
53
+ @registries.each { |name, records| s += render_registry_row(name, records) }
54
+ s + "</table>\n"
55
+ end
56
+
57
+ def render_registry_row(name, records)
58
+ group = @configuration.get_risk_rpn_groups(name).first
59
+ values = group ? records.filter_map { |r| r.rpn_value(group) } : []
60
+ s = "\t<tr>\n"
61
+ s += "\t\t<td class=\"item_text\" style='padding: 5px;'>\
62
+ <a name=\"#{name}\" id=\"#{name}\" href=\"./#{name}/overview.html\" class=\"external\" \
63
+ title=\"Risk Registry\">#{registry_title(name)}</a></td>\n"
64
+ s += "\t\t<td class=\"item_rpn\">#{records.length}</td>\n"
65
+ s += "\t\t<td class=\"item_rpn\">#{open_count(records)}</td>\n"
66
+ s += render_highest_cell(values, group)
67
+ s += render_average_cell(values)
68
+ s + "\t</tr>\n"
69
+ end
70
+
71
+ # The registry preface's frontmatter title (ENH-221) — the same source the
72
+ # registry page heading uses — or the folder name when the registry has no
73
+ # preface or the preface carries no title.
74
+ def registry_title(name)
75
+ params = @prefaces[name]&.frontmatter&.parameters
76
+ (params && params['title']) || name
77
+ end
78
+
79
+ # Every record whose marked status is not Closed counts as open — including
80
+ # records with no current-status marker (ADR-219 keeps the count honest to
81
+ # the marker; another terminal status is the registry preface's to document).
82
+ def open_count(records)
83
+ records.count { |r| r.current_status.to_s.strip != OPEN_EXCLUDED_STATUS }
84
+ end
85
+
86
+ # The worst risk's verdict carries up: the cell keeps the leading group's
87
+ # threshold colouring. Blank without a group or computable values.
88
+ def render_highest_cell(values, group)
89
+ return "\t\t<td class=\"item_rpn\"></td>\n" if values.empty?
90
+
91
+ highest = values.max
92
+ classes = ['item_rpn', rpn_threshold_class(highest, group)].compact.join(' ')
93
+ "\t\t<td class=\"#{classes}\">#{format_rpn(highest)}</td>\n"
94
+ end
95
+
96
+ def render_average_cell(values)
97
+ return "\t\t<td class=\"item_rpn\"></td>\n" if values.empty?
98
+
99
+ average = (values.sum.to_f / values.length).round(1)
100
+ "\t\t<td class=\"item_rpn\">#{format_rpn(average)}</td>\n"
101
+ end
102
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared RPN cell presentation (ADR-217, reused by the registries summary of
4
+ # ADR-219): the threshold band class and the integer-when-whole formatting.
5
+ module RpnRendering
6
+ # Acceptable at or below the acceptable bound, unacceptable at or above the
7
+ # unacceptable bound, caution between them (the ALARP band); a lone bound
8
+ # leaves the rest of the range as caution. nil when no thresholds configured.
9
+ def rpn_threshold_class(value, group)
10
+ if group[:acceptable] && value <= group[:acceptable]
11
+ 'rpn_acceptable'
12
+ elsif group[:unacceptable] && value >= group[:unacceptable]
13
+ 'rpn_unacceptable'
14
+ elsif group[:acceptable] || group[:unacceptable]
15
+ 'rpn_caution'
16
+ end
17
+ end
18
+
19
+ # Whole values print as integers (8 * 3 -> 24, not 24.0).
20
+ def format_rpn(value)
21
+ value == value.to_i ? value.to_i.to_s : value.to_s
22
+ end
23
+ end
@@ -3,7 +3,8 @@ require_relative '../link_registry'
3
3
  class ProjectData
4
4
  attr_reader :specifications, :protocols, :traceability_matrices, :coverage_matrices, :source_files,
5
5
  :specifications_dictionary, :covered_specifications_dictionary, :implemented_specifications_dictionary,
6
- :implementation_matrices, :decisions, :decision_groups, :work_items, :link_registry
6
+ :implementation_matrices, :decisions, :decision_groups, :risk_records, :risk_registries,
7
+ :risk_registry_prefaces, :work_items, :link_registry
7
8
 
8
9
  def initialize
9
10
  @specifications = []
@@ -16,6 +17,14 @@ class ProjectData
16
17
  # Insertion-ordered list of single-key hashes { "<first-level folder>" => [Decision, ...] },
17
18
  # grouping decision records by the planning folder they live in (see ADR-197).
18
19
  @decision_groups = []
20
+ @risk_records = []
21
+ # Insertion-ordered list of single-key hashes { "<first-level risks/ folder>" => [RiskRecord, ...] },
22
+ # grouping risk records by the registry they live in (see ADR-215).
23
+ @risk_registries = []
24
+ # Registry prefaces (ADR-216): each registry's parsed overview.md, keyed by
25
+ # the registry (first-level risks/ folder) name. A registry without an
26
+ # overview.md has no entry; its page simply starts at the register table.
27
+ @risk_registry_prefaces = {}
19
28
  # Every Scope-row WorkItem across all decision records, keyed by its canonical
20
29
  # "<record>.<step>.<activity>" id (see ADR-194), populated by link_work_items.
21
30
  @work_items = {}
@@ -5,7 +5,9 @@ require 'set'
5
5
  # Lays the WorkItem network (ADR-194) on an abstract day axis for the overview
6
6
  # swimlane Gantt (ADR-198). It performs a deterministic forward pass over the
7
7
  # dependency edges and then levels each owner's lane so two work items of the
8
- # same owner never overlap.
8
+ # same owner never overlap. Levelling backfills: a row may be slotted into an
9
+ # idle gap left between rows placed earlier, so a low-priority short row does
10
+ # not queue behind the whole lane when a gap already fits it.
9
11
  #
10
12
  # Durations are a constant placeholder (3 days) while no per-row estimates
11
13
  # exist; `duration_for` is the single hook ADR-195 overrides to feed real
@@ -68,57 +70,56 @@ class WorkItemScheduler
68
70
 
69
71
  # Greedy list-scheduler. Items are processed in a deterministic priority order
70
72
  # (resource-free earliest start, then activity rank, record id, step) so that
71
- # every item's predecessors are placed before it. Each item starts at the later
72
- # of its dependency finish and its owner's next free day; the owner's cursor
73
- # then advances past it, serialising the lane (resource levelling).
73
+ # every item's predecessors are placed before it. Each item starts at the
74
+ # earliest day at or after its dependency finish where its owner's lane has an
75
+ # idle gap wide enough for it (resource levelling with backfill).
74
76
  def schedule
75
77
  @starts = {}
76
78
  @ends = {}
77
79
  @binding = {}
78
- @owner_last = {}
80
+ @owner_rows = Hash.new { |hash, owner| hash[owner] = [] }
79
81
  return if @items.empty?
80
82
 
81
- owner_free = Hash.new(1)
82
- priority_order.each { |wi| place(wi, owner_free) }
83
+ priority_order.each { |wi| place(wi) }
83
84
  end
84
85
 
85
- # Assigns one work item its start day at the later of its dependency finish and
86
- # its owner's next free day, records the predecessor that bound that start, then
87
- # advances that owner's free cursor past it.
88
- def place(work_item, owner_free)
89
- owner = work_item.owner
86
+ # Assigns one work item its start day, records the predecessor that bound that
87
+ # start, then marks the span as occupied in its owner's lane.
88
+ def place(work_item)
90
89
  preds = scoped_predecessors(work_item)
91
- start = start_day(preds, owner_free[owner])
90
+ dep_finish = preds.map { |p| @ends[p] || 1 }.max || 1
91
+ start, lane_pred = earliest_fit(work_item.owner, dep_finish, duration_for(work_item))
92
92
  @starts[work_item] = start
93
93
  @ends[work_item] = start + duration_for(work_item)
94
- @binding[work_item] = binding_predecessor(work_item, preds, start, owner)
95
- advance_owner(owner, work_item, owner_free)
94
+ @binding[work_item] = binding_predecessor(preds, start, lane_pred)
95
+ @owner_rows[work_item.owner] << work_item unless work_item.owner.empty?
96
96
  end
97
97
 
98
- # The earliest day a row may start: the later of its in-scope predecessors'
99
- # latest finish and its owner's next free day.
100
- def start_day(preds, owner_free_day)
101
- dep_finish = preds.map { |p| @ends[p] || 1 }.max || 1
102
- [dep_finish, owner_free_day].max
103
- end
98
+ # The earliest start at or after `from` where the owner's lane stays clear for
99
+ # `duration` days, plus the lane row whose finish that start had to wait behind
100
+ # (nil when the row starts at `from` itself). A blank owner holds no resource,
101
+ # so it never serialises.
102
+ def earliest_fit(owner, from, duration)
103
+ return [from, nil] if owner.empty?
104
104
 
105
- # Advance an owner's free cursor past the just-placed row, and remember it as
106
- # that owner's most recent row (the resource hand-off candidate). A blank owner
107
- # holds no resource, so it never serialises.
108
- def advance_owner(owner, work_item, owner_free)
109
- return if owner.empty?
105
+ start = from
106
+ lane_pred = nil
107
+ @owner_rows[owner].sort_by { |row| @starts[row] }.each do |row|
108
+ break if start + duration <= @starts[row]
109
+ next if @ends[row] <= start
110
110
 
111
- owner_free[owner] = @ends[work_item]
112
- @owner_last[owner] = work_item
111
+ start = @ends[row]
112
+ lane_pred = row
113
+ end
114
+ [start, lane_pred]
113
115
  end
114
116
 
115
117
  # The already-placed predecessor whose finish coincides with this row's start --
116
118
  # the dependency or same-owner hand-off the critical chain is traced back
117
119
  # through. nil when the row starts at the origin with no such predecessor.
118
- def binding_predecessor(_work_item, preds, start, owner)
120
+ def binding_predecessor(preds, start, lane_pred)
119
121
  candidates = preds.select { |p| @ends[p] == start }
120
- resource_pred = @owner_last[owner]
121
- candidates << resource_pred if resource_pred && @ends[resource_pred] == start
122
+ candidates << lane_pred if lane_pred
122
123
  candidates.min_by { |c| [c.record_id, c.step] }
123
124
  end
124
125
 
@@ -45,10 +45,12 @@ class Project
45
45
  parse_all_protocols
46
46
  parse_all_source_files
47
47
  parse_decisions
48
+ parse_risks
48
49
  link_all_specifications
49
50
  link_all_protocols
50
51
  link_all_source_files
51
52
  link_all_decisions
53
+ link_all_risks
52
54
  check_wrong_specification_referenced
53
55
  build_link_registry
54
56
  link_work_items
@@ -62,6 +64,9 @@ class Project
62
64
  render_decisions_overview
63
65
  render_critical_chain_page
64
66
  render_all_decisions
67
+ render_all_risk_records
68
+ render_risk_registry_pages
69
+ render_risks_overview
65
70
  render_index
66
71
  create_search_data
67
72
  report_broken_links
@@ -74,10 +79,12 @@ class Project
74
79
  parse_test_run test_run
75
80
  parse_all_source_files
76
81
  parse_decisions
82
+ parse_risks
77
83
  link_all_specifications
78
84
  link_all_protocols
79
85
  link_all_source_files
80
86
  link_all_decisions
87
+ link_all_risks
81
88
  check_wrong_specification_referenced
82
89
  build_link_registry
83
90
  link_work_items
@@ -91,6 +98,9 @@ class Project
91
98
  render_decisions_overview
92
99
  render_critical_chain_page
93
100
  render_all_decisions
101
+ render_all_risk_records
102
+ render_risk_registry_pages
103
+ render_risks_overview
94
104
  render_index
95
105
  create_search_data
96
106
  report_broken_links
@@ -238,6 +248,10 @@ class Project
238
248
  d.output_rel_path = "decisions/#{d.html_rel_path}"
239
249
  reg.register(d)
240
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
241
255
  @project_data.source_files.each do |d|
242
256
  rel = d.path.sub("#{d.root_path}/", '')
243
257
  d.output_rel_path = "source_files/#{d.repository}/#{rel}.html"
@@ -309,6 +323,77 @@ class Project
309
323
  end
310
324
  end
311
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
+
312
397
  def parse_test_run(test_run)
313
398
  path = @configuration.project_root_directory
314
399
  Dir.glob("#{path}/tests/runs/#{test_run}/**/*.md").each do |f|
@@ -366,6 +451,22 @@ class Project
366
451
  ConsoleReporter.count('decision links', number_of_links)
367
452
  end
368
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
+
369
470
  def link_all_source_files
370
471
  return unless DocLinker.link_all_source_files(@project_data)
371
472
 
@@ -535,6 +636,66 @@ class Project
535
636
  end
536
637
  end
537
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
+
538
699
  def create_search_data
539
700
  db = SpecificationsDb.new @project_data.specifications
540
701
  data_path = "#{@configuration.project_root_directory}/build/data"
@@ -106,6 +106,29 @@ class ProjectConfiguration
106
106
  value
107
107
  end
108
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
+
109
132
  def is_spec_db_shall_be_created
110
133
  if @parameters.key? 'output'
111
134
  @parameters['output'].each do |p|
@@ -115,6 +138,33 @@ class ProjectConfiguration
115
138
  false
116
139
  end
117
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
+
118
168
  # A value under the planning: key, or nil when planning is absent.
119
169
  def planning_value(key)
120
170
  return nil unless @parameters.is_a?(Hash)
@@ -505,4 +505,16 @@ img{
505
505
  padding: 5px;
506
506
  text-align:center;
507
507
  background:#fff;
508
- }
508
+ }/* Risk register RPN threshold bands (ADR-217): acceptable / ALARP caution / unacceptable */
509
+ td.item_rpn {
510
+ text-align: center;
511
+ }
512
+ td.rpn_acceptable {
513
+ background-color: #d5f5e3;
514
+ }
515
+ td.rpn_caution {
516
+ background-color: #fdebd0;
517
+ }
518
+ td.rpn_unacceptable {
519
+ background-color: #fadbd8;
520
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: Almirah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleksandr Ivanov
@@ -90,6 +90,10 @@ files:
90
90
  - lib/almirah/doc_types/persistent_document.rb
91
91
  - lib/almirah/doc_types/planning_dates.rb
92
92
  - lib/almirah/doc_types/protocol.rb
93
+ - lib/almirah/doc_types/risk_record.rb
94
+ - lib/almirah/doc_types/risk_registry_page.rb
95
+ - lib/almirah/doc_types/risks_overview.rb
96
+ - lib/almirah/doc_types/rpn_rendering.rb
93
97
  - lib/almirah/doc_types/source_file.rb
94
98
  - lib/almirah/doc_types/specification.rb
95
99
  - lib/almirah/doc_types/traceability.rb
@@ -134,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
138
  - !ruby/object:Gem::Version
135
139
  version: '0'
136
140
  requirements: []
137
- rubygems_version: 4.0.6
141
+ rubygems_version: 4.0.1
138
142
  specification_version: 4
139
143
  summary: Almirah
140
144
  test_files: []