Almirah 0.4.2 → 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/bin/almirah +2 -2
- data/lib/almirah/doc_fabric.rb +25 -0
- data/lib/almirah/doc_items/blockquote.rb +15 -16
- data/lib/almirah/doc_items/code_block.rb +19 -21
- data/lib/almirah/doc_items/controlled_paragraph.rb +4 -4
- data/lib/almirah/doc_items/controlled_table.rb +11 -11
- data/lib/almirah/doc_items/controlled_table_row.rb +15 -18
- data/lib/almirah/doc_items/doc_footer.rb +10 -13
- data/lib/almirah/doc_items/doc_item.rb +2 -2
- data/lib/almirah/doc_items/heading.rb +1 -1
- data/lib/almirah/doc_items/image.rb +25 -27
- data/lib/almirah/doc_items/markdown_list.rb +2 -2
- data/lib/almirah/doc_items/markdown_table.rb +9 -2
- data/lib/almirah/doc_items/scope_table.rb +188 -0
- data/lib/almirah/doc_items/text_line.rb +26 -22
- data/lib/almirah/doc_items/todo_block.rb +15 -16
- data/lib/almirah/doc_items/work_item.rb +129 -0
- data/lib/almirah/doc_parser.rb +15 -8
- data/lib/almirah/doc_types/base_document.rb +31 -7
- data/lib/almirah/doc_types/coverage.rb +3 -3
- data/lib/almirah/doc_types/critical_chain_page.rb +218 -0
- data/lib/almirah/doc_types/decision.rb +152 -7
- data/lib/almirah/doc_types/decision_grouping.rb +17 -0
- data/lib/almirah/doc_types/decisions_overview.rb +591 -31
- data/lib/almirah/doc_types/implementation.rb +98 -98
- data/lib/almirah/doc_types/index.rb +11 -12
- data/lib/almirah/doc_types/persistent_document.rb +1 -1
- data/lib/almirah/doc_types/planning_dates.rb +17 -0
- data/lib/almirah/doc_types/protocol.rb +16 -20
- 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/doc_types/source_file.rb +1 -1
- data/lib/almirah/doc_types/traceability.rb +124 -133
- data/lib/almirah/dom/doc_section.rb +1 -1
- data/lib/almirah/navigation_pane.rb +9 -13
- data/lib/almirah/project/critical_chain.rb +117 -0
- data/lib/almirah/project/doc_linker.rb +4 -4
- data/lib/almirah/project/fever_chart.rb +94 -0
- data/lib/almirah/project/project_data.rb +17 -2
- data/lib/almirah/project/work_item_scheduler.rb +167 -0
- data/lib/almirah/project/working_calendar.rb +112 -0
- data/lib/almirah/project.rb +307 -9
- data/lib/almirah/project_configuration.rb +176 -29
- data/lib/almirah/project_template.rb +6 -6
- data/lib/almirah/project_utility.rb +3 -5
- data/lib/almirah/search/specifications_db.rb +2 -2
- data/lib/almirah/source_file_parser.rb +2 -3
- data/lib/almirah/templates/css/main.css +173 -1
- data/lib/almirah/templates/scripts/main.js +3 -1
- data/lib/almirah.rb +1 -2
- metadata +14 -1
data/lib/almirah/project.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require_relative 'doc_fabric'
|
|
5
|
+
require_relative 'doc_items/work_item'
|
|
5
6
|
require_relative 'navigation_pane'
|
|
6
7
|
require_relative 'doc_types/traceability'
|
|
7
8
|
require_relative 'doc_types/index'
|
|
@@ -12,7 +13,7 @@ require_relative 'project/project_data'
|
|
|
12
13
|
require_relative 'console_reporter'
|
|
13
14
|
require_relative 'relative_url'
|
|
14
15
|
|
|
15
|
-
class Project
|
|
16
|
+
class Project
|
|
16
17
|
attr_accessor :index, :project, :configuration, :project_data
|
|
17
18
|
|
|
18
19
|
def initialize(configuration)
|
|
@@ -39,17 +40,20 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
39
40
|
FileUtils.copy_entry(src_folder, dst_folder)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
def specifications_and_protocols
|
|
43
|
+
def specifications_and_protocols
|
|
43
44
|
parse_all_specifications
|
|
44
45
|
parse_all_protocols
|
|
45
46
|
parse_all_source_files
|
|
46
47
|
parse_decisions
|
|
48
|
+
parse_risks
|
|
47
49
|
link_all_specifications
|
|
48
50
|
link_all_protocols
|
|
49
51
|
link_all_source_files
|
|
50
52
|
link_all_decisions
|
|
53
|
+
link_all_risks
|
|
51
54
|
check_wrong_specification_referenced
|
|
52
55
|
build_link_registry
|
|
56
|
+
link_work_items
|
|
53
57
|
create_index
|
|
54
58
|
render_all_specifications(@project_data.specifications)
|
|
55
59
|
render_all_specifications(@project_data.traceability_matrices)
|
|
@@ -58,24 +62,32 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
58
62
|
render_all_source_files
|
|
59
63
|
render_all_specifications(@project_data.implementation_matrices) # intentionally after source file rendering
|
|
60
64
|
render_decisions_overview
|
|
65
|
+
render_critical_chain_page
|
|
61
66
|
render_all_decisions
|
|
67
|
+
render_all_risk_records
|
|
68
|
+
render_risk_registry_pages
|
|
69
|
+
render_risks_overview
|
|
62
70
|
render_index
|
|
63
71
|
create_search_data
|
|
64
72
|
report_broken_links
|
|
73
|
+
report_kit_violations
|
|
65
74
|
report_rendered
|
|
66
75
|
end
|
|
67
76
|
|
|
68
|
-
def specifications_and_results(test_run)
|
|
77
|
+
def specifications_and_results(test_run)
|
|
69
78
|
parse_all_specifications
|
|
70
79
|
parse_test_run test_run
|
|
71
80
|
parse_all_source_files
|
|
72
81
|
parse_decisions
|
|
82
|
+
parse_risks
|
|
73
83
|
link_all_specifications
|
|
74
84
|
link_all_protocols
|
|
75
85
|
link_all_source_files
|
|
76
86
|
link_all_decisions
|
|
87
|
+
link_all_risks
|
|
77
88
|
check_wrong_specification_referenced
|
|
78
89
|
build_link_registry
|
|
90
|
+
link_work_items
|
|
79
91
|
create_index
|
|
80
92
|
render_all_specifications(@project_data.specifications)
|
|
81
93
|
render_all_specifications(@project_data.traceability_matrices)
|
|
@@ -84,10 +96,15 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
84
96
|
render_all_source_files
|
|
85
97
|
render_all_specifications(@project_data.implementation_matrices) # intentionally after source file rendering
|
|
86
98
|
render_decisions_overview
|
|
99
|
+
render_critical_chain_page
|
|
87
100
|
render_all_decisions
|
|
101
|
+
render_all_risk_records
|
|
102
|
+
render_risk_registry_pages
|
|
103
|
+
render_risks_overview
|
|
88
104
|
render_index
|
|
89
105
|
create_search_data
|
|
90
106
|
report_broken_links
|
|
107
|
+
report_kit_violations
|
|
91
108
|
report_rendered
|
|
92
109
|
end
|
|
93
110
|
|
|
@@ -107,10 +124,115 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
107
124
|
broken.each { |b| puts ConsoleReporter.warn_detail(" #{b[:document] || '?'}: #{b[:target]}") }
|
|
108
125
|
end
|
|
109
126
|
|
|
127
|
+
# Builds the per-row WorkItem dependency network (ADR-194): registers every
|
|
128
|
+
# Scope-row work item, fills the intra-record step-order edges, then resolves
|
|
129
|
+
# each Depends On reference globally (LinkRegistry) to the activity-type-aligned
|
|
130
|
+
# work item of the target record, tagging each cross-record edge in-group or
|
|
131
|
+
# cross-group against the decision_groups boundary (ADR-197). Unresolved
|
|
132
|
+
# references are collected for report_kit_violations. Runs after
|
|
133
|
+
# build_link_registry (so every record resolves) and before rendering (so the
|
|
134
|
+
# overview Kit column is ready).
|
|
135
|
+
def link_work_items
|
|
136
|
+
@kit_unresolved = []
|
|
137
|
+
@project_data.decisions.each do |d|
|
|
138
|
+
d.scope_work_items.each { |wi| @project_data.work_items[wi.id] = wi }
|
|
139
|
+
end
|
|
140
|
+
link_intra_record_steps
|
|
141
|
+
link_cross_record_dependencies
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Each row's lower-numbered same-record steps are its predecessors; equal step
|
|
145
|
+
# numbers are concurrent (no edge). These edges are in-group by definition.
|
|
146
|
+
def link_intra_record_steps
|
|
147
|
+
@project_data.decisions.each do |d|
|
|
148
|
+
items = d.scope_work_items
|
|
149
|
+
items.each do |wi|
|
|
150
|
+
items.each do |other|
|
|
151
|
+
next if other.equal?(wi) || other.step >= wi.step
|
|
152
|
+
|
|
153
|
+
wi.add_predecessor(other, cross_group: false)
|
|
154
|
+
other.add_successor(wi)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def link_cross_record_dependencies
|
|
161
|
+
@project_data.decisions.each do |d|
|
|
162
|
+
d.scope_work_items.each do |wi|
|
|
163
|
+
wi.depends_on_refs.each { |ref| link_dependency(d, wi, ref) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def link_dependency(record, work_item, ref)
|
|
169
|
+
target = @project_data.link_registry.find_by_id(ref)
|
|
170
|
+
unless target.is_a?(Decision)
|
|
171
|
+
@kit_unresolved << { record: record.id, target: ref }
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
prereq = aligned_work_item(target, work_item.activity)
|
|
175
|
+
return if prereq.nil? || prereq.equal?(work_item)
|
|
176
|
+
|
|
177
|
+
cross = decision_group_name(record) != decision_group_name(target)
|
|
178
|
+
work_item.add_predecessor(prereq, cross_group: cross)
|
|
179
|
+
prereq.add_successor(work_item)
|
|
180
|
+
anchor = target.scope_table&.step_column? ? prereq.row_anchor : nil
|
|
181
|
+
work_item.add_resolved_dependency(ref, target, anchor, prereq.id)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The target record's work item whose activity (Item) matches `activity`,
|
|
185
|
+
# falling back to the nearest earlier activity by canonical phase order, then
|
|
186
|
+
# (when the target has only later activities) to its earliest row. nil only
|
|
187
|
+
# when the target has no Scope rows.
|
|
188
|
+
def aligned_work_item(target, activity)
|
|
189
|
+
items = target.scope_work_items
|
|
190
|
+
return nil if items.empty?
|
|
191
|
+
|
|
192
|
+
exact = items.select { |t| t.activity == activity }.min_by(&:step)
|
|
193
|
+
return exact if exact
|
|
194
|
+
|
|
195
|
+
rank = WorkItem::ACTIVITY_ORDER.index(activity) || WorkItem::ACTIVITY_ORDER.length
|
|
196
|
+
earlier = items.select { |t| t.activity_rank <= rank }
|
|
197
|
+
return items.min_by { |t| [t.activity_rank, t.step] } if earlier.empty?
|
|
198
|
+
|
|
199
|
+
earlier.min_by { |t| [-t.activity_rank, t.step] }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The planning-group name (first-level decisions/ folder) a record belongs to,
|
|
203
|
+
# read from the decision_groups collection (ADR-197).
|
|
204
|
+
def decision_group_name(doc)
|
|
205
|
+
group = @project_data.decision_groups.find { |g| g.values.first.include?(doc) }
|
|
206
|
+
group&.keys&.first
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Reports the two kit gates and unresolved Depends On references (ADR-194), all
|
|
210
|
+
# as non-failing console warnings alongside report_broken_links.
|
|
211
|
+
def report_kit_violations
|
|
212
|
+
@kit_unresolved ||= []
|
|
213
|
+
phase = @project_data.work_items.each_value.select(&:phase_order_violation?)
|
|
214
|
+
cross = @project_data.work_items.each_value.select(&:cross_record_violation?)
|
|
215
|
+
total = phase.length + cross.length + @kit_unresolved.length
|
|
216
|
+
return if total.zero?
|
|
217
|
+
|
|
218
|
+
ConsoleReporter.warn('kit violations', total)
|
|
219
|
+
phase.each do |wi|
|
|
220
|
+
blocking = wi.intra_record_predecessors.reject(&:done?).map(&:id).join(', ')
|
|
221
|
+
puts ConsoleReporter.warn_detail(" phase order: #{wi.id} started before #{blocking}")
|
|
222
|
+
end
|
|
223
|
+
cross.each do |wi|
|
|
224
|
+
blocking = wi.cross_record_predecessors.reject(&:done?).map(&:id).join(', ')
|
|
225
|
+
puts ConsoleReporter.warn_detail(" not kitted: #{wi.id} needs #{blocking}")
|
|
226
|
+
end
|
|
227
|
+
@kit_unresolved.each do |u|
|
|
228
|
+
puts ConsoleReporter.warn_detail(" unresolved Depends On: #{u[:record]} -> #{u[:target]}")
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
110
232
|
# Assigns each document its generated output path (relative to the build root)
|
|
111
233
|
# and registers it for cross-document link resolution (ADR-186). Runs after all
|
|
112
234
|
# documents are parsed and before any rendering, so link targets are known.
|
|
113
|
-
def build_link_registry
|
|
235
|
+
def build_link_registry
|
|
114
236
|
reg = @project_data.link_registry
|
|
115
237
|
TextLine.link_registry = reg
|
|
116
238
|
TextLine.reset_broken_links
|
|
@@ -126,6 +248,10 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
126
248
|
d.output_rel_path = "decisions/#{d.html_rel_path}"
|
|
127
249
|
reg.register(d)
|
|
128
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
|
|
129
255
|
@project_data.source_files.each do |d|
|
|
130
256
|
rel = d.path.sub("#{d.root_path}/", '')
|
|
131
257
|
d.output_rel_path = "source_files/#{d.repository}/#{rel}.html"
|
|
@@ -177,11 +303,97 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
177
303
|
rel_dir = File.dirname(f.sub("#{decisions_root}/", ''))
|
|
178
304
|
doc.html_rel_path = rel_dir == '.' ? "#{doc.id}.html" : "#{rel_dir}/#{doc.id}.html"
|
|
179
305
|
@project_data.decisions.append(doc)
|
|
306
|
+
add_to_decision_group(doc, rel_dir)
|
|
180
307
|
end
|
|
181
308
|
BaseDocument.show_decisions_link = @project_data.decisions.any?
|
|
182
309
|
ConsoleReporter.count('parsing decisions', @project_data.decisions.length)
|
|
183
310
|
end
|
|
184
311
|
|
|
312
|
+
# Add a decision record to its planning group, keyed on the first-level folder
|
|
313
|
+
# under decisions/ (a record directly under decisions/ has rel_dir '.', kept as
|
|
314
|
+
# its own '.' group rather than dropped). Groups are single-key hashes appended
|
|
315
|
+
# in folder-encounter order; the matching one is reused. See ADR-197.
|
|
316
|
+
def add_to_decision_group(doc, rel_dir)
|
|
317
|
+
group_name = rel_dir.split('/').first
|
|
318
|
+
group = @project_data.decision_groups.find { |g| g.key?(group_name) }
|
|
319
|
+
if group.nil?
|
|
320
|
+
@project_data.decision_groups.append({ group_name => [doc] })
|
|
321
|
+
else
|
|
322
|
+
group[group_name].append(doc)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
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
|
+
|
|
185
397
|
def parse_test_run(test_run)
|
|
186
398
|
path = @configuration.project_root_directory
|
|
187
399
|
Dir.glob("#{path}/tests/runs/#{test_run}/**/*.md").each do |f|
|
|
@@ -190,7 +402,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
190
402
|
end
|
|
191
403
|
end
|
|
192
404
|
|
|
193
|
-
def link_all_specifications
|
|
405
|
+
def link_all_specifications
|
|
194
406
|
comb_list = @project_data.specifications.combination(2)
|
|
195
407
|
comb_list.each do |c|
|
|
196
408
|
link_two_specifications(c[0], c[1])
|
|
@@ -209,7 +421,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
209
421
|
ConsoleReporter.count('traceability matrices', @project_data.traceability_matrices.length)
|
|
210
422
|
end
|
|
211
423
|
|
|
212
|
-
def link_all_protocols
|
|
424
|
+
def link_all_protocols
|
|
213
425
|
@project_data.protocols.each do |p|
|
|
214
426
|
@project_data.specifications.each do |s|
|
|
215
427
|
if p.up_link_docs.key?(s.id.to_s)
|
|
@@ -239,6 +451,22 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
239
451
|
ConsoleReporter.count('decision links', number_of_links)
|
|
240
452
|
end
|
|
241
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
|
+
|
|
242
470
|
def link_all_source_files
|
|
243
471
|
return unless DocLinker.link_all_source_files(@project_data)
|
|
244
472
|
|
|
@@ -250,7 +478,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
250
478
|
ConsoleReporter.count('implementation matrices', @project_data.implementation_matrices.length)
|
|
251
479
|
end
|
|
252
480
|
|
|
253
|
-
def check_wrong_specification_referenced
|
|
481
|
+
def check_wrong_specification_referenced
|
|
254
482
|
available_specification_ids = {}
|
|
255
483
|
|
|
256
484
|
@project_data.specifications.each do |s|
|
|
@@ -280,7 +508,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
280
508
|
end
|
|
281
509
|
end
|
|
282
510
|
|
|
283
|
-
def link_two_specifications(doc_a, doc_b)
|
|
511
|
+
def link_two_specifications(doc_a, doc_b)
|
|
284
512
|
if doc_b.up_link_docs.key?(doc_a.id.to_s)
|
|
285
513
|
top_document = doc_a
|
|
286
514
|
bottom_document = doc_b
|
|
@@ -383,7 +611,17 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
383
611
|
doc.to_html("#{path}/build/decisions/")
|
|
384
612
|
end
|
|
385
613
|
|
|
386
|
-
def
|
|
614
|
+
def render_critical_chain_page
|
|
615
|
+
return if @project_data.decisions.empty?
|
|
616
|
+
|
|
617
|
+
path = @configuration.project_root_directory
|
|
618
|
+
FileUtils.mkdir_p("#{path}/build/decisions")
|
|
619
|
+
|
|
620
|
+
doc = DocFabric.create_critical_chain_page(@project)
|
|
621
|
+
doc.to_html("#{path}/build/decisions/")
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def render_all_decisions
|
|
387
625
|
return if @project_data.decisions.empty?
|
|
388
626
|
|
|
389
627
|
build_decisions_root = "#{@configuration.project_root_directory}/build/decisions"
|
|
@@ -398,6 +636,66 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
398
636
|
end
|
|
399
637
|
end
|
|
400
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
|
+
|
|
401
699
|
def create_search_data
|
|
402
700
|
db = SpecificationsDb.new @project_data.specifications
|
|
403
701
|
data_path = "#{@configuration.project_root_directory}/build/data"
|
|
@@ -1,42 +1,189 @@
|
|
|
1
1
|
require 'yaml'
|
|
2
|
+
require 'date'
|
|
2
3
|
|
|
3
4
|
class ProjectConfiguration
|
|
4
|
-
|
|
5
|
+
DEFAULT_WIP_LIMIT = 2
|
|
6
|
+
DEFAULT_BUFFER_RATIO = 0.5
|
|
7
|
+
DEFAULT_HOURS_PER_DAY = 8
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
@project_root_directory = File.expand_path(path)
|
|
8
|
-
@parameters = {}
|
|
9
|
-
load_project_file
|
|
10
|
-
end
|
|
9
|
+
attr_accessor :project_root_directory, :parameters
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
puts 'Project file not found: project.yml'
|
|
18
|
-
end
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@project_root_directory = File.expand_path(path)
|
|
13
|
+
@parameters = {}
|
|
14
|
+
load_project_file
|
|
15
|
+
end
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
def load_project_file
|
|
18
|
+
@parameters = YAML.load_file(@project_root_directory + '/project.yml')
|
|
19
|
+
rescue Psych::SyntaxError => e
|
|
20
|
+
puts "YAML syntax error: #{e.message}"
|
|
21
|
+
rescue Errno::ENOENT
|
|
22
|
+
puts 'Project file not found: project.yml'
|
|
23
|
+
end
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
def get_design_inputs
|
|
26
|
+
return @parameters['specifications']['input'] if (@parameters.key? 'specifications') and (@parameters['specifications'].key? 'input')
|
|
27
|
+
|
|
28
|
+
[]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_repositories
|
|
32
|
+
return @parameters['repositories'] if @parameters.key? 'repositories'
|
|
33
|
+
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_wip_limit
|
|
38
|
+
return DEFAULT_WIP_LIMIT unless @parameters.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
planning = @parameters['planning']
|
|
41
|
+
return DEFAULT_WIP_LIMIT unless planning.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
value = planning['wip_limit']
|
|
44
|
+
return DEFAULT_WIP_LIMIT unless value.is_a?(Integer) && value.positive?
|
|
45
|
+
|
|
46
|
+
value
|
|
47
|
+
end
|
|
27
48
|
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
# The CCPM project-buffer ratio (ADR-195): a fraction in (0, 1] cutting the
|
|
50
|
+
# aggregated chain safety. Absent or out-of-range falls back to the 0.5 default.
|
|
51
|
+
def get_buffer_ratio
|
|
52
|
+
return DEFAULT_BUFFER_RATIO unless @parameters.is_a?(Hash)
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
planning = @parameters['planning']
|
|
55
|
+
return DEFAULT_BUFFER_RATIO unless planning.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
value = planning['buffer_ratio']
|
|
58
|
+
return DEFAULT_BUFFER_RATIO unless value.is_a?(Numeric) && value.positive? && value <= 1
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The calendar anchor for working day 1 (ADR-205), a Date parsed from a
|
|
64
|
+
# DD-MM-YYYY planning.start_date. Absent or unparseable falls back to today,
|
|
65
|
+
# so an unconfigured project still renders (a moving anchor).
|
|
66
|
+
def get_start_date
|
|
67
|
+
date = parse_planning_date(planning_value('start_date'))
|
|
68
|
+
date || Date.today
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Per-group planning start dates (ADR-211): a map from a decision group's
|
|
72
|
+
# first-level folder name (under decisions/) to its start Date, read from
|
|
73
|
+
# planning.groups as DD-MM-YYYY entries. Non-string or unparseable values are
|
|
74
|
+
# dropped; empty when unset. A group absent from the map sequences after the
|
|
75
|
+
# previous group rather than carrying a declared start.
|
|
76
|
+
def get_group_start_dates
|
|
77
|
+
value = planning_value('groups')
|
|
78
|
+
return {} unless value.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
value.each_with_object({}) do |(name, raw), acc|
|
|
81
|
+
date = parse_planning_date(raw)
|
|
82
|
+
acc[name.to_s] = date if date
|
|
32
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)
|
|
33
102
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
103
|
+
value = planning['hours_per_day']
|
|
104
|
+
return DEFAULT_HOURS_PER_DAY unless value.is_a?(Numeric) && value.positive?
|
|
105
|
+
|
|
106
|
+
value
|
|
107
|
+
end
|
|
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
|
+
|
|
132
|
+
def is_spec_db_shall_be_created
|
|
133
|
+
if @parameters.key? 'output'
|
|
134
|
+
@parameters['output'].each do |p|
|
|
135
|
+
return true if p == 'specifications_db'
|
|
136
|
+
end
|
|
41
137
|
end
|
|
138
|
+
false
|
|
139
|
+
end
|
|
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
|
+
|
|
168
|
+
# A value under the planning: key, or nil when planning is absent.
|
|
169
|
+
def planning_value(key)
|
|
170
|
+
return nil unless @parameters.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
planning = @parameters['planning']
|
|
173
|
+
planning.is_a?(Hash) ? planning[key] : nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Parse a planning date that may already be a Date (YAML ISO form) or a
|
|
177
|
+
# DD-MM-YYYY string; nil when neither.
|
|
178
|
+
def parse_planning_date(value)
|
|
179
|
+
return value if value.is_a?(Date)
|
|
180
|
+
return nil unless value.is_a?(String)
|
|
181
|
+
|
|
182
|
+
match = /\A(\d{2})-(\d{2})-(\d{4})\z/.match(value.strip)
|
|
183
|
+
return nil unless match
|
|
184
|
+
|
|
185
|
+
Date.new(match[3].to_i, match[2].to_i, match[1].to_i)
|
|
186
|
+
rescue ArgumentError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
42
189
|
end
|