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