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.
@@ -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, :link_registry
7
8
 
8
9
  def initialize
9
10
  @specifications = []
@@ -16,9 +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 = []
19
- # Every Scope-row WorkItem across all decision records, keyed by its canonical
20
- # "<record>.<step>.<activity>" id (see ADR-194), populated by link_work_items.
21
- @work_items = {}
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 = {}
22
28
 
23
29
  @specifications_dictionary = {}
24
30
  @covered_specifications_dictionary = {}