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 +4 -4
- data/lib/almirah/doc_fabric.rb +18 -0
- data/lib/almirah/doc_items/controlled_paragraph.rb +2 -2
- data/lib/almirah/doc_parser.rb +4 -3
- data/lib/almirah/doc_types/base_document.rb +10 -2
- data/lib/almirah/doc_types/risk_record.rb +77 -0
- data/lib/almirah/doc_types/risk_registry_page.rb +141 -0
- data/lib/almirah/doc_types/risks_overview.rb +102 -0
- data/lib/almirah/doc_types/rpn_rendering.rb +23 -0
- data/lib/almirah/project/project_data.rb +10 -1
- data/lib/almirah/project/work_item_scheduler.rb +32 -31
- data/lib/almirah/project.rb +161 -0
- data/lib/almirah/project_configuration.rb +50 -0
- data/lib/almirah/templates/css/main.css +13 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b53b0b8a1c024c618e1fff5aac5288312b51b2a06f228527f06183b31f114ac
|
|
4
|
+
data.tar.gz: 41959a8eaba117737d913afd63bda6947301e3a5a84aac96b94030f43f6a4564
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66edfea3c57bb86b6cc5e63de0b63579ec756a12b2704db2c86d2956124e7a9a89fd6e1db86f5481c18840bb4378acbb11d1a5db250d14ccfc7a699c7e0f614e
|
|
7
|
+
data.tar.gz: ddbd78d3135822cf967a6fac7c2ae87986dbf79aa537b403dfc15c41ffbc36c7594c9460cb086b067d70b7492aee4f0bc81c48b40b07caf02ea70b22fffd915b
|
data/lib/almirah/doc_fabric.rb
CHANGED
|
@@ -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=\"
|
|
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=\"
|
|
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>'
|
data/lib/almirah/doc_parser.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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?
|
|
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} 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} 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} 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, :
|
|
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
|
|
72
|
-
#
|
|
73
|
-
#
|
|
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
|
-
@
|
|
80
|
+
@owner_rows = Hash.new { |hash, owner| hash[owner] = [] }
|
|
79
81
|
return if @items.empty?
|
|
80
82
|
|
|
81
|
-
|
|
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
|
|
86
|
-
#
|
|
87
|
-
|
|
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
|
-
|
|
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(
|
|
95
|
-
|
|
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
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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(
|
|
120
|
+
def binding_predecessor(preds, start, lane_pred)
|
|
119
121
|
candidates = preds.select { |p| @ends[p] == start }
|
|
120
|
-
|
|
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
|
|
data/lib/almirah/project.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
141
|
+
rubygems_version: 4.0.1
|
|
138
142
|
specification_version: 4
|
|
139
143
|
summary: Almirah
|
|
140
144
|
test_files: []
|