Almirah 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3c6b1d68ee25c9b62690c4cdda45ec2d790960a6e75762576ddc60d6c3136e5
4
- data.tar.gz: 334ef7251595119cd27bf980eeb53a8f02d913cde7d2fc5d0844a1d863b6c825
3
+ metadata.gz: b4a6b0c92714af081d54b235449ab825fa86842d61869f31cd331a44c702efdc
4
+ data.tar.gz: cf2074e5c3f500689359912872166c1f13c08e59bde29408c90ee7b34fbe18fe
5
5
  SHA512:
6
- metadata.gz: 20383b85a3d59d35a3c6259095e7e8b6709055b6670e2b651fdecdd814eee54b71ebf7d2cc1958c85bc998a426361dfff470c1c10749044334ae7ec7f1a69616
7
- data.tar.gz: d1d066ecfc0cfe644eaa28f42d793342e328f42d3eefc0e1fdeb2290c8ff281009bd59b99d23893604f89927ab698a922ef844af1e702c45a3a0a5d51926ff84
6
+ metadata.gz: 21654dccede23f1828f02b4446f11f626d12ee5d7d3689b96636d6b1be0465de44fddbacd05606935326a27d4317bfaf0b5596813e907a38580ea6591bc430ca
7
+ data.tar.gz: d3821259aeb7e5ff6306c3d9ccec25720030e659e351cd2c32a7791a8520ddea0039e6cab7f083b0244c82095e7c219c14de465bcda55b326eca184d06ce3bc7
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reporter prints concise, aligned progress lines for the processing pipeline:
4
+ #
5
+ # parsing specifications ..... 4 ok
6
+ # parsing test protocols ..... 2 ok
7
+ # parsing decisions .......... 14 ok
8
+ # traceability matrices ...... 4 ok
9
+ # coverage matrices .......... 1 ok
10
+ # implementation matrices .... 1 ok
11
+ # decision links ............. 5 ok
12
+ # rendering HTML ............. ./build/index.html
13
+ #
14
+ # ANSI colour is applied only when writing to an interactive terminal, so piped
15
+ # or captured output stays clean.
16
+ module ConsoleReporter
17
+ COLUMN = 28 # label is dot-padded out to this column, then the value follows
18
+
19
+ module_function
20
+
21
+ def count(label, number)
22
+ emit(label, "#{number} ok", 92) # green highlight
23
+ end
24
+
25
+ def result(label, value)
26
+ emit(label, value, 96) # cyan highlight
27
+ end
28
+
29
+ def warn(label, value)
30
+ emit(label, value.to_s, 91) # red highlight
31
+ end
32
+
33
+ # Colourises a detail line with the same red as warn (TTY-gated).
34
+ def warn_detail(text)
35
+ colorize(text, 91)
36
+ end
37
+
38
+ def emit(label, value, color_code)
39
+ dotted = "#{label} ".ljust(COLUMN, '.')
40
+ puts "#{dotted} #{colorize(value, color_code)}"
41
+ end
42
+
43
+ def colorize(text, color_code)
44
+ $stdout.tty? ? "\e[#{color_code}m#{text}\e[0m" : text
45
+ end
46
+ end
@@ -17,12 +17,6 @@ class DocFabric
17
17
  @@spec_colors = %w[cff4d2 fbeee6 ffcad4 bce6ff e4dfd9 f9e07f cbe54e d3e7ee eab595 86e3c3
18
18
  ffdca2 ffffff ffdd94 d0e6a5 ffb284 f3dbcf c9bbc8 c6c09c]
19
19
 
20
- def self.add_lazy_doc_id(path)
21
- if res = /(\w+)[.]md$/.match(path)
22
- TextLine.add_lazy_doc_id(res[1])
23
- end
24
- end
25
-
26
20
  def self.create_specification(path)
27
21
  color = @@spec_colors[@@color_index]
28
22
  @@color_index += 1
@@ -151,7 +151,7 @@ class ControlledTable < DocItem # rubocop:disable Style/Documentation
151
151
  end
152
152
 
153
153
  def add_row(row)
154
- columns = row.split('|')
154
+ columns = split_table_cells(row)
155
155
 
156
156
  @rows.append(format_columns(columns))
157
157
 
@@ -19,4 +19,16 @@ class DocItem < TextLine # rubocop:disable Style/Documentation
19
19
  def get_url
20
20
  ''
21
21
  end
22
+
23
+ # Splits a Markdown table row into its cell strings, treating a backslash-escaped
24
+ # pipe (\|) as a literal character within a cell rather than a column separator
25
+ # (e.g. a "|" inside an inline code span such as `[[target\|alias]]`). The escaping
26
+ # backslash is dropped so the cell renders the intended literal pipe.
27
+ def split_table_cells(row)
28
+ row.split(/(?<!\\)\|/).map { |cell| cell.gsub('\\|', '|') }
29
+ end
30
+
31
+ def owner_document
32
+ @parent_doc
33
+ end
22
34
  end
@@ -12,7 +12,7 @@ class MarkdownTable < DocItem
12
12
 
13
13
  res = /^[|](.*[|])/.match(heading_row)
14
14
  @column_names = if res
15
- res[1].split('|')
15
+ split_table_cells(res[1])
16
16
  else
17
17
  ['# ERROR# ']
18
18
  end
@@ -49,7 +49,7 @@ class MarkdownTable < DocItem
49
49
  end
50
50
 
51
51
  def add_row(row)
52
- columns = row.split('|')
52
+ columns = split_table_cells(row)
53
53
  @rows.append(columns.map!(&:strip))
54
54
  true
55
55
  end
@@ -1,4 +1,6 @@
1
1
  require 'cgi'
2
+ require 'uri'
3
+ require_relative '../relative_url'
2
4
 
3
5
  class TextLineToken
4
6
  attr_accessor :value
@@ -50,6 +52,18 @@ class SquareBracketRight < TextLineToken
50
52
  end
51
53
  end
52
54
 
55
+ class DoubleSquareBracketLeft < TextLineToken
56
+ def initialize # rubocop:disable Lint/MissingSuper
57
+ @value = '[['
58
+ end
59
+ end
60
+
61
+ class DoubleSquareBracketRight < TextLineToken
62
+ def initialize # rubocop:disable Lint/MissingSuper
63
+ @value = ']]'
64
+ end
65
+ end
66
+
53
67
  class SquareBracketRightAndParentheseLeft < TextLineToken
54
68
  def initialize
55
69
  @value = ']('
@@ -77,6 +91,8 @@ class TextLineParser
77
91
  @supported_tokens.append(BoldToken.new)
78
92
  @supported_tokens.append(ItalicToken.new)
79
93
  @supported_tokens.append(BacktickToken.new)
94
+ @supported_tokens.append(DoubleSquareBracketLeft.new)
95
+ @supported_tokens.append(DoubleSquareBracketRight.new)
80
96
  @supported_tokens.append(SquareBracketRightAndParentheseLeft.new)
81
97
  @supported_tokens.append(ParentheseLeft.new)
82
98
  @supported_tokens.append(ParentheseRight.new)
@@ -220,6 +236,10 @@ class TextLineBuilderContext
220
236
  def link(_link_text, link_url)
221
237
  link_url
222
238
  end
239
+
240
+ def wiki_link(inner)
241
+ "[[#{inner}]]"
242
+ end
223
243
  end
224
244
 
225
245
  class TextLineBuilder
@@ -229,7 +249,7 @@ class TextLineBuilder
229
249
  @builder_context = builder_context
230
250
  end
231
251
 
232
- def restore(token_list)
252
+ def restore(token_list) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
233
253
  result = ''
234
254
  return '' if token_list.nil?
235
255
 
@@ -296,6 +316,25 @@ class TextLineBuilder
296
316
  result += '***'
297
317
  ti = ti_starting_position + 1
298
318
  end
319
+ when 'DoubleSquareBracketLeft'
320
+ # wiki/Obsidian link: collect raw text up to the closing "]]"
321
+ is_found = false
322
+ ti_starting_position = ti
323
+ tii = ti + 1
324
+ while tii < tl
325
+ if token_list[tii].instance_of?(DoubleSquareBracketRight)
326
+ inner = token_list[(ti + 1)..(tii - 1)].map(&:value).join
327
+ result += @builder_context.wiki_link(inner)
328
+ ti = tii + 1
329
+ is_found = true
330
+ break
331
+ end
332
+ tii += 1
333
+ end
334
+ unless is_found
335
+ result += '[['
336
+ ti = ti_starting_position + 1
337
+ end
299
338
  when 'SquareBracketLeft'
300
339
  # try to find closing part
301
340
  is_found = false
@@ -333,7 +372,7 @@ class TextLineBuilder
333
372
  when 'InlineCodeToken'
334
373
  result += @builder_context.inline_code(token_list[ti].value)
335
374
  ti += 1
336
- when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight'
375
+ when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight', 'DoubleSquareBracketRight'
337
376
  result += token_list[ti].value
338
377
  ti += 1
339
378
  else
@@ -345,11 +384,37 @@ class TextLineBuilder
345
384
  end
346
385
 
347
386
  class TextLine < TextLineBuilderContext
348
- @@lazy_doc_id_dict = {}
387
+ @@link_registry = nil # rubocop:disable Style/ClassVars
388
+ @@broken_links = [] # rubocop:disable Style/ClassVars
389
+
390
+ class << self
391
+ def link_registry=(registry)
392
+ @@link_registry = registry # rubocop:disable Style/ClassVars
393
+ end
349
394
 
350
- def self.add_lazy_doc_id(id)
351
- doc_id = id.to_s.downcase
352
- @@lazy_doc_id_dict[doc_id] = doc_id
395
+ def link_registry
396
+ @@link_registry
397
+ end
398
+
399
+ # Cross-document links that could not be resolved to a managed document,
400
+ # collected during rendering for reporting (ADR-186, SRS-094).
401
+ def broken_links
402
+ @@broken_links
403
+ end
404
+
405
+ def reset_broken_links
406
+ @@broken_links = [] # rubocop:disable Style/ClassVars
407
+ end
408
+
409
+ def record_broken_link(document, target)
410
+ @@broken_links << { document: document&.id, target: target }
411
+ end
412
+ end
413
+
414
+ # The document that owns this text line. Used to resolve cross-document links
415
+ # relative to the current page. nil for stand-alone text (e.g. unit tests).
416
+ def owner_document
417
+ nil
353
418
  end
354
419
 
355
420
  def format_string(str)
@@ -374,30 +439,60 @@ class TextLine < TextLineBuilderContext
374
439
  "<code class=\"inline\">#{CGI.escapeHTML(str)}</code>"
375
440
  end
376
441
 
377
- def link(link_text, link_url)
378
- # define default result first
379
- result = "<a target=\"_blank\" rel=\"noopener\" href=\"#{link_url}\" class=\"external\">#{link_text}</a>"
442
+ def link(link_text, link_url) # rubocop:disable Metrics/MethodLength
443
+ raw = link_url.to_s
444
+ kind, target, fragment = classify_markdown_link(raw)
445
+ case kind
446
+ when :internal
447
+ href = RelativeUrl.between(owner_document.output_rel_path, target.output_rel_path, fragment: fragment)
448
+ "<a href=\"#{href}\" class=\"external\">#{link_text}</a>"
449
+ when :broken
450
+ TextLine.record_broken_link(owner_document, raw)
451
+ "<a href=\"#{raw}\" class=\"broken_link\" title=\"Unresolved cross-document link\">#{link_text}</a>"
452
+ else
453
+ "<a target=\"_blank\" rel=\"noopener\" href=\"#{raw}\" class=\"external\">#{link_text}</a>"
454
+ end
455
+ end
456
+
457
+ # Resolves an Obsidian/wiki link "[[target#fragment|alias]]" to a managed
458
+ # document by its unique id/filename, independent of folder (ADR-186).
459
+ def wiki_link(inner) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize,Metrics/MethodLength
460
+ link_part, sep, alias_text = inner.partition('|')
461
+ target, _hash, fragment = link_part.partition('#')
462
+ display = (sep.empty? ? link_part : alias_text).strip
463
+ display = link_part.strip if display.empty?
464
+
465
+ doc = TextLine.link_registry&.find_by_id(target.strip)
466
+ if doc && owner_document&.output_rel_path
467
+ href = RelativeUrl.between(owner_document.output_rel_path, doc.output_rel_path,
468
+ fragment: fragment.strip.empty? ? nil : fragment.strip)
469
+ "<a href=\"#{href}\" class=\"external\">#{CGI.escapeHTML(display)}</a>"
470
+ elsif owner_document&.output_rel_path
471
+ TextLine.record_broken_link(owner_document, "[[#{inner}]]")
472
+ "<span class=\"broken_link\" title=\"Unresolved wiki link\">#{CGI.escapeHTML(display)}</span>"
473
+ else
474
+ "[[#{inner}]]"
475
+ end
476
+ end
477
+
478
+ private
380
479
 
381
- lazy_doc_id = nil
382
- anchor = nil
480
+ # Classifies a Markdown link target as :internal (a managed document, resolved
481
+ # against the owning document's source directory), :broken (a local .md path
482
+ # that does not resolve), or :external (everything else). ADR-186.
483
+ def classify_markdown_link(raw) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
484
+ path_part, _sep, fragment = raw.partition('#')
485
+ path_part = URI::DEFAULT_PARSER.unescape(path_part) # decode %20 etc. to match the real file path
486
+ return [:external] unless local_markdown_path?(path_part)
383
487
 
384
- if res = /(\w+)[.]md$/.match(link_url) # link
385
- lazy_doc_id = res[1].to_s.downcase
488
+ doc = owner_document
489
+ return [:external] unless doc&.output_rel_path && doc.respond_to?(:path) && doc.path && TextLine.link_registry
386
490
 
387
- elsif res = /(\w*)[.]md(#.*)$/.match(link_url) # link with anchor
388
- if res && res.length > 2
389
- lazy_doc_id = res[1]
390
- anchor = res[2]
391
- end
392
- end
491
+ target = TextLine.link_registry.find_by_source(File.expand_path(path_part, File.dirname(doc.path)))
492
+ target ? [:internal, target, fragment.empty? ? nil : fragment] : [:broken]
493
+ end
393
494
 
394
- if lazy_doc_id && @@lazy_doc_id_dict.key?(lazy_doc_id)
395
- result = if anchor
396
- "<a href=\".\\..\\#{lazy_doc_id}\\#{lazy_doc_id}.html#{anchor}\" class=\"external\">#{link_text}</a>"
397
- else
398
- "<a href=\".\\..\\#{lazy_doc_id}\\#{lazy_doc_id}.html\" class=\"external\">#{link_text}</a>"
399
- end
400
- end
401
- result
495
+ def local_markdown_path?(path_part)
496
+ path_part.match?(/\.(md|markdown)\z/i) && !path_part.match?(%r{\A[a-z][a-z0-9+.\-]*://}i)
402
497
  end
403
498
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../relative_url'
4
+
3
5
  class BaseDocument # rubocop:disable Style/Documentation
4
- attr_accessor :title, :id, :dom, :headings
6
+ attr_accessor :title, :id, :dom, :headings, :output_rel_path
5
7
 
6
8
  class << self
7
9
  attr_accessor :show_decisions_link
@@ -32,6 +34,7 @@ class BaseDocument # rubocop:disable Style/Documentation
32
34
  else
33
35
  "#{@id}/#{@id}.html"
34
36
  end
37
+ @output_rel_path = output_file_path.split('/build/', 2).last
35
38
  file = File.open(output_file_path, 'w')
36
39
  file_data.each do |s| # rubocop:disable Metrics/BlockLength
37
40
  if s.include?('{{CONTENT}}')
@@ -43,51 +46,21 @@ class BaseDocument # rubocop:disable Style/Documentation
43
46
  elsif s.include?('{{DOCUMENT_TITLE}}')
44
47
  file.puts s.gsub! '{{DOCUMENT_TITLE}}', @title
45
48
  elsif s.include?('{{STYLES_AND_SCRIPTS}}')
49
+ file.puts "<link rel=\"stylesheet\" href=\"#{rel_to('css/main.css')}\">"
50
+ file.puts "<script src=\"#{rel_to('scripts/main.js')}\"></script>"
46
51
  if @id == 'index'
47
- file.puts '<script type="module" src="./scripts/orama_search.js"></script>'
48
- file.puts '<link rel="stylesheet" href="./css/search.css">'
49
- file.puts '<link rel="stylesheet" href="./css/main.css">'
50
- file.puts '<script src="./scripts/main.js"></script>'
51
- elsif instance_of? Specification
52
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
53
- file.puts '<script src="../../scripts/main.js"></script>'
54
- elsif instance_of? Traceability
55
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
56
- file.puts '<script src="../../scripts/main.js"></script>'
57
- elsif instance_of? Coverage
58
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
59
- file.puts '<script src="../../scripts/main.js"></script>'
60
- elsif instance_of? Implementation
61
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
62
- file.puts '<script src="../../scripts/main.js"></script>'
63
- elsif instance_of? Protocol
64
- file.puts '<link rel="stylesheet" href="../../../css/main.css">'
65
- file.puts '<script src="../../../scripts/main.js"></script>'
52
+ file.puts "<script type=\"module\" src=\"#{rel_to('scripts/orama_search.js')}\"></script>"
53
+ file.puts "<link rel=\"stylesheet\" href=\"#{rel_to('css/search.css')}\">"
66
54
  elsif instance_of? DecisionsOverview
67
- file.puts '<link rel="stylesheet" href="../css/main.css">'
68
- file.puts '<script src="../scripts/main.js"></script>'
69
55
  file.puts '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
70
- elsif instance_of? Decision
71
- file.puts "<link rel=\"stylesheet\" href=\"#{root_prefix}css/main.css\">"
72
- file.puts "<script src=\"#{root_prefix}scripts/main.js\"></script>"
73
56
  end
74
57
  elsif s.include?('{{HOME_BUTTON}}')
75
58
  if @id == 'index'
76
- file.puts '<a id="home_menu_item" href="./index.html"><span><i class="fa fa-home" aria-hidden="true"></i></span>&nbsp;Home</a>'
77
- file.puts decisions_link('./decisions/overview.html') if BaseDocument.show_decisions_link
78
- elsif instance_of? Protocol
79
- file.puts '<a id="index_menu_item" href="./../../../index.html"><span><i class="fa fa-info" aria-hidden="true"></i></span>&nbsp;Index</a>'
80
- file.puts decisions_link('./../../../decisions/overview.html') if BaseDocument.show_decisions_link
81
- elsif instance_of? DecisionsOverview
82
- file.puts index_link('./../index.html')
83
- file.puts decisions_link('./overview.html')
84
- elsif instance_of? Decision
85
- file.puts index_link("#{root_prefix}index.html")
86
- file.puts decisions_link("#{root_prefix}decisions/overview.html")
59
+ file.puts home_link(rel_to('index.html'))
87
60
  else
88
- file.puts '<a id="index_menu_item" href="./../../index.html"><span><i class="fa fa-info" aria-hidden="true"></i></span>&nbsp;Index</a>'
89
- file.puts decisions_link('./../../decisions/overview.html') if BaseDocument.show_decisions_link
61
+ file.puts index_link(rel_to('index.html'))
90
62
  end
63
+ file.puts decisions_link(rel_to('decisions/overview.html')) if BaseDocument.show_decisions_link
91
64
  elsif s.include?('{{GEM_VERSION}}')
92
65
  file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
93
66
  else
@@ -106,4 +79,14 @@ class BaseDocument # rubocop:disable Style/Documentation
106
79
  icon = '<span><i class="fa fa-info" aria-hidden="true"></i></span>'
107
80
  %(<a id="index_menu_item" href="#{href}">#{icon}&nbsp;Index</a>)
108
81
  end
82
+
83
+ def home_link(href)
84
+ icon = '<span><i class="fa fa-home" aria-hidden="true"></i></span>'
85
+ %(<a id="home_menu_item" href="#{href}">#{icon}&nbsp;Home</a>)
86
+ end
87
+
88
+ # Relative URL from this page to a target path under the build root.
89
+ def rel_to(target)
90
+ RelativeUrl.between(@output_rel_path, target)
91
+ end
109
92
  end
@@ -72,6 +72,10 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
72
72
  [75, 192, 192], [153, 102, 255], [201, 203, 207]
73
73
  ].freeze
74
74
 
75
+ # Records with a missing or ambiguous current-status marker are surfaced under
76
+ # this category as a data-quality indicator rather than being silently dropped.
77
+ UNDEFINED_STATUS_LABEL = 'Undefined'
78
+
75
79
  def render_charts_grid # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
76
80
  counts = @project.project_data.decisions.each_with_object(Hash.new(0)) do |item, cntr|
77
81
  cntr[item.record_type] += 1 if item.record_type
@@ -81,6 +85,7 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
81
85
  pie_colors = labels.each_with_index.map { |_, i| palette_rgba(i, 0.5) }
82
86
 
83
87
  velocity = velocity_chart_data
88
+ status_dist = status_distribution_chart_data
84
89
 
85
90
  <<~HTML
86
91
  <div class="decisions_overview_charts">
@@ -115,7 +120,20 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
115
120
  \t\t\t});
116
121
  \t\t</script>
117
122
  \t</div>
118
- \t<div class="chart_cell"></div>
123
+ \t<div class="chart_cell">
124
+ \t\t<canvas id="decisions_status_bar"></canvas>
125
+ \t\t<script>
126
+ \t\t\tnew Chart(document.getElementById('decisions_status_bar'), {
127
+ \t\t\t\ttype: 'bar',
128
+ \t\t\t\tdata: #{status_dist.to_json},
129
+ \t\t\t\toptions: {
130
+ \t\t\t\t\tindexAxis: 'y',
131
+ \t\t\t\t\tplugins: { title: { display: true, text: 'Decision Records by Current Status' }, legend: { display: false } },
132
+ \t\t\t\t\tscales: { x: { beginAtZero: true, ticks: { precision: 0 } } }
133
+ \t\t\t\t}
134
+ \t\t\t});
135
+ \t\t</script>
136
+ \t</div>
119
137
  </div>
120
138
  HTML
121
139
  end
@@ -144,6 +162,32 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
144
162
  }
145
163
  end
146
164
 
165
+ # Tally each decision record under its current status (the "*"-marked Status
166
+ # row). Records whose current status is undefined fall under "Undefined", which
167
+ # is ordered last; real statuses keep first-seen order. The count is baked into
168
+ # each label so small categories stay legible on a linear axis next to a
169
+ # dominant one.
170
+ def status_distribution_chart_data # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
171
+ counts = Hash.new(0)
172
+ keys = []
173
+ @project.project_data.decisions.each do |doc|
174
+ status = doc.current_status
175
+ key = status.nil? || status.strip.empty? ? UNDEFINED_STATUS_LABEL : status
176
+ keys << key unless counts.key?(key)
177
+ counts[key] += 1
178
+ end
179
+ keys << UNDEFINED_STATUS_LABEL if keys.delete(UNDEFINED_STATUS_LABEL)
180
+
181
+ colors = keys.each_with_index.map do |k, i|
182
+ k == UNDEFINED_STATUS_LABEL ? palette_rgba(CHART_PALETTE.length - 1, 0.5) : palette_rgba(i, 0.5)
183
+ end
184
+ {
185
+ labels: keys.map { |k| "#{k} (#{counts[k]})" },
186
+ datasets: [{ label: 'Decision records', data: keys.map { |k| counts[k] },
187
+ backgroundColor: colors, borderWidth: 0 }]
188
+ }
189
+ end
190
+
147
191
  def palette_rgba(index, alpha)
148
192
  r, g, b = CHART_PALETTE[index % CHART_PALETTE.length]
149
193
  "rgba(#{r}, #{g}, #{b}, #{alpha})"
@@ -25,7 +25,6 @@ class SourceFile < PersistentDocument
25
25
  depth = relative_path.count('/') + 1 # +1 for the repository folder
26
26
  depth += 1 # for the source_files folder
27
27
  @specifications_path = "./#{'../' * depth}specifications/"
28
- puts @specifications_path
29
28
  end
30
29
 
31
30
  def to_console
@@ -78,13 +77,10 @@ class SourceFile < PersistentDocument
78
77
  @html_file_path = output_file_path
79
78
  FileUtils.mkdir_p(File.dirname(output_file_path))
80
79
 
81
- # Calculate the relative path depth to determine correct number of parent directory symbols
82
- relative_path = @path.sub("#{@root_path}/", '')
83
- depth = relative_path.count('/') + 1 # +1 for the repository folder
84
- depth += 1 # for the source_files folder
85
- css_path = "#{'../' * depth}css/main.css"
86
- js_path = "#{'../' * depth}scripts/main.js"
87
- index_path = "#{'../' * depth}index.html"
80
+ @output_rel_path = output_file_path.split('/build/', 2).last
81
+ css_path = rel_to('css/main.css')
82
+ js_path = rel_to('scripts/main.js')
83
+ index_path = rel_to('index.html')
88
84
 
89
85
  file = File.open(output_file_path, 'w')
90
86
  file_data.each do |s|
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Project-wide registry of managed documents, used to resolve cross-document
4
+ # links (ADR-186) to their generated output pages. Documents are indexed by
5
+ # their id / filename stem (case-insensitive, folder-independent) and, when they
6
+ # originate from a source file, by their absolute source path (for native
7
+ # Markdown relative links).
8
+ class LinkRegistry
9
+ attr_reader :collisions
10
+
11
+ def initialize
12
+ @by_id = {}
13
+ @by_source = {}
14
+ @collisions = []
15
+ end
16
+
17
+ # Each registered document is expected to already carry an output_rel_path
18
+ # (its generated page, relative to the build root).
19
+ def register(doc)
20
+ key = doc.id.to_s.downcase
21
+ if @by_id.key?(key) && !@by_id[key].equal?(doc)
22
+ @collisions << key
23
+ else
24
+ @by_id[key] = doc
25
+ end
26
+ return unless doc.respond_to?(:path) && doc.path
27
+
28
+ @by_source[File.expand_path(doc.path)] = doc
29
+ end
30
+
31
+ def find_by_id(id)
32
+ @by_id[id.to_s.downcase]
33
+ end
34
+
35
+ def find_by_source(source_path)
36
+ @by_source[File.expand_path(source_path)]
37
+ end
38
+ end
@@ -1,9 +1,11 @@
1
+ require_relative '../link_registry'
2
+
1
3
  class ProjectData
2
4
  attr_reader :specifications, :protocols, :traceability_matrices, :coverage_matrices, :source_files,
3
5
  :specifications_dictionary, :covered_specifications_dictionary, :implemented_specifications_dictionary,
4
- :implementation_matrices, :decisions
6
+ :implementation_matrices, :decisions, :link_registry
5
7
 
6
- def initialize
8
+ def initialize # rubocop:disable Metrics/MethodLength
7
9
  @specifications = []
8
10
  @protocols = []
9
11
  @traceability_matrices = []
@@ -15,5 +17,7 @@ class ProjectData
15
17
  @specifications_dictionary = {}
16
18
  @covered_specifications_dictionary = {}
17
19
  @implemented_specifications_dictionary = {}
20
+
21
+ @link_registry = LinkRegistry.new
18
22
  end
19
23
  end
@@ -9,6 +9,8 @@ require_relative 'search/specifications_db'
9
9
  require_relative 'project/doc_linker'
10
10
  require_relative 'project_configuration'
11
11
  require_relative 'project/project_data'
12
+ require_relative 'console_reporter'
13
+ require_relative 'relative_url'
12
14
 
13
15
  class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
14
16
  attr_accessor :index, :project, :configuration, :project_data
@@ -47,6 +49,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
47
49
  link_all_source_files
48
50
  link_all_decisions
49
51
  check_wrong_specification_referenced
52
+ build_link_registry
50
53
  create_index
51
54
  render_all_specifications(@project_data.specifications)
52
55
  render_all_specifications(@project_data.traceability_matrices)
@@ -58,6 +61,8 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
58
61
  render_all_decisions
59
62
  render_index
60
63
  create_search_data
64
+ report_broken_links
65
+ report_rendered
61
66
  end
62
67
 
63
68
  def specifications_and_results(test_run) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
@@ -70,6 +75,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
70
75
  link_all_source_files
71
76
  link_all_decisions
72
77
  check_wrong_specification_referenced
78
+ build_link_registry
73
79
  create_index
74
80
  render_all_specifications(@project_data.specifications)
75
81
  render_all_specifications(@project_data.traceability_matrices)
@@ -81,20 +87,60 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
81
87
  render_all_decisions
82
88
  render_index
83
89
  create_search_data
90
+ report_broken_links
91
+ report_rendered
92
+ end
93
+
94
+ def report_rendered
95
+ root = @configuration.project_root_directory
96
+ base = root == Dir.pwd ? '.' : root
97
+ ConsoleReporter.result('rendering HTML', File.join(base, 'build', 'index.html'))
98
+ end
99
+
100
+ # Reports cross-document links that could not be resolved (ADR-186, SRS-094),
101
+ # naming the linking document. The build still completes.
102
+ def report_broken_links
103
+ broken = TextLine.broken_links
104
+ return if broken.empty?
105
+
106
+ ConsoleReporter.warn('broken links', broken.length)
107
+ broken.each { |b| puts ConsoleReporter.warn_detail(" #{b[:document] || '?'}: #{b[:target]}") }
108
+ end
109
+
110
+ # Assigns each document its generated output path (relative to the build root)
111
+ # and registers it for cross-document link resolution (ADR-186). Runs after all
112
+ # documents are parsed and before any rendering, so link targets are known.
113
+ def build_link_registry # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
114
+ reg = @project_data.link_registry
115
+ TextLine.link_registry = reg
116
+ TextLine.reset_broken_links
117
+ @project_data.specifications.each do |d|
118
+ d.output_rel_path = "specifications/#{d.id}/#{d.id}.html"
119
+ reg.register(d)
120
+ end
121
+ @project_data.protocols.each do |d|
122
+ d.output_rel_path = "tests/protocols/#{d.id}/#{d.id}.html"
123
+ reg.register(d)
124
+ end
125
+ @project_data.decisions.each do |d|
126
+ d.output_rel_path = "decisions/#{d.html_rel_path}"
127
+ reg.register(d)
128
+ end
129
+ @project_data.source_files.each do |d|
130
+ rel = d.path.sub("#{d.root_path}/", '')
131
+ d.output_rel_path = "source_files/#{d.repository}/#{rel}.html"
132
+ reg.register(d)
133
+ end
84
134
  end
85
135
 
86
136
  def parse_all_specifications
87
137
  path = @configuration.project_root_directory
88
- # do a lasy pass first to get the list of documents id
89
138
  Dir.glob("#{path}/specifications/**/*.md").each do |f|
90
- DocFabric.add_lazy_doc_id(f)
91
- end
92
- # parse documents in the second pass
93
- Dir.glob("#{path}/specifications/**/*.md").each do |f| # rubocop:disable Style/CombinableLoops
94
139
  doc = DocFabric.create_specification(f)
95
140
  @project_data.specifications.append(doc)
96
141
  @project_data.specifications_dictionary[doc.id.to_s.downcase] = doc
97
142
  end
143
+ ConsoleReporter.count('parsing specifications', @project_data.specifications.length)
98
144
  end
99
145
 
100
146
  def parse_all_protocols
@@ -103,6 +149,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
103
149
  doc = DocFabric.create_protocol(f)
104
150
  @project_data.protocols.append(doc)
105
151
  end
152
+ ConsoleReporter.count('parsing test protocols', @project_data.protocols.length)
106
153
  end
107
154
 
108
155
  def parse_all_source_files
@@ -132,6 +179,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
132
179
  @project_data.decisions.append(doc)
133
180
  end
134
181
  BaseDocument.show_decisions_link = @project_data.decisions.any?
182
+ ConsoleReporter.count('parsing decisions', @project_data.decisions.length)
135
183
  end
136
184
 
137
185
  def parse_test_run(test_run)
@@ -158,6 +206,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
158
206
  @project_data.traceability_matrices.append doc
159
207
  end
160
208
  end
209
+ ConsoleReporter.count('traceability matrices', @project_data.traceability_matrices.length)
161
210
  end
162
211
 
163
212
  def link_all_protocols # rubocop:disable Metrics/MethodLength
@@ -174,16 +223,20 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
174
223
  doc = DocFabric.create_coverage_matrix(value)
175
224
  @project_data.coverage_matrices.append doc
176
225
  end
226
+ ConsoleReporter.count('coverage matrices', @project_data.coverage_matrices.length)
177
227
  end
178
228
 
179
229
  def link_all_decisions
230
+ number_of_links = 0
180
231
  @project_data.decisions.each do |d|
181
232
  @project_data.specifications.each do |s|
182
233
  next unless d.up_link_docs.key?(s.id.to_s)
183
234
 
184
235
  DocLinker.link_decision_to_spec(d, s)
236
+ number_of_links += 1
185
237
  end
186
238
  end
239
+ ConsoleReporter.count('decision links', number_of_links)
187
240
  end
188
241
 
189
242
  def link_all_source_files
@@ -194,6 +247,7 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
194
247
  doc = DocFabric.create_implementation_document(value)
195
248
  @project_data.implementation_matrices.append doc
196
249
  end
250
+ ConsoleReporter.count('implementation matrices', @project_data.implementation_matrices.length)
197
251
  end
198
252
 
199
253
  def check_wrong_specification_referenced # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
@@ -267,14 +321,12 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
267
321
  @index = Index.new(@project)
268
322
  end
269
323
 
270
- def render_all_specifications(spec_list) # rubocop:disable Metrics/MethodLength
324
+ def render_all_specifications(spec_list)
271
325
  path = @configuration.project_root_directory
272
326
 
273
327
  FileUtils.mkdir_p("#{path}/build/specifications")
274
328
 
275
329
  spec_list.each do |doc|
276
- doc.to_console
277
-
278
330
  img_src_dir = "#{path}/specifications/#{doc.id}/img"
279
331
  img_dst_dir = "#{path}/build/specifications/#{doc.id}/img"
280
332
 
@@ -310,8 +362,6 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
310
362
  FileUtils.mkdir_p("#{path}/build/source_files")
311
363
 
312
364
  @project_data.source_files.each do |doc|
313
- doc.to_console
314
-
315
365
  doc.to_html("#{path}/build/source_files/")
316
366
  end
317
367
  end
@@ -320,8 +370,6 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
320
370
  path = @configuration.project_root_directory
321
371
 
322
372
  doc = @index
323
- doc.to_console
324
-
325
373
  doc.to_html("#{path}/build/")
326
374
  end
327
375
 
@@ -332,7 +380,6 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
332
380
  FileUtils.mkdir_p("#{path}/build/decisions")
333
381
 
334
382
  doc = DocFabric.create_decisions_overview(@project)
335
- doc.to_console
336
383
  doc.to_html("#{path}/build/decisions/")
337
384
  end
338
385
 
@@ -347,7 +394,6 @@ class Project # rubocop:disable Metrics/ClassLength,Style/Documentation
347
394
  depth = 1 + (out_dir_rel == '.' ? 0 : out_dir_rel.split('/').size)
348
395
  doc.root_prefix = '../' * depth
349
396
  doc.specifications_path = "./#{doc.root_prefix}specifications/"
350
- doc.to_console
351
397
  doc.to_html(NavigationPane.new(doc), "#{out_dir}/")
352
398
  end
353
399
  end
@@ -4,7 +4,7 @@ class ProjectConfiguration
4
4
  attr_accessor :project_root_directory, :parameters
5
5
 
6
6
  def initialize(path)
7
- @project_root_directory = path
7
+ @project_root_directory = File.expand_path(path)
8
8
  @parameters = {}
9
9
  load_project_file
10
10
  end
@@ -14,6 +14,7 @@ class ProjectTemplate # rubocop:disable Style/Documentation
14
14
  create_architecture
15
15
  create_tests
16
16
  create_test_runs
17
+ create_decisions
17
18
  end
18
19
 
19
20
  def create_requirements
@@ -265,7 +266,7 @@ class ProjectTemplate # rubocop:disable Style/Documentation
265
266
  end
266
267
 
267
268
  def run_test_003
268
- path = File.join(@project_root, 'tests/runs/010/tq-002')
269
+ path = File.join(@project_root, 'tests/runs/010/tq-001')
269
270
  FileUtils.mkdir_p path
270
271
 
271
272
  file_content = <<~EOS
@@ -295,4 +296,91 @@ class ProjectTemplate # rubocop:disable Style/Documentation
295
296
  file.puts file_content
296
297
  file.close
297
298
  end
299
+
300
+ def create_decisions
301
+ path = File.join(@project_root, 'decisions')
302
+ FileUtils.mkdir_p path
303
+
304
+ file_content = <<~EOS
305
+ ---
306
+ title: "ADR-001: Start Project Decision"
307
+ ---
308
+
309
+ # Status
310
+
311
+ | | Date | Status |
312
+ |:---:|---|---|
313
+ | * | #{Time.now.strftime('%d-%m-%Y')} | Proposed |
314
+
315
+ # Context
316
+
317
+ This is an example decision record. It demonstrates how Almirah captures a
318
+ decision and links it to the requirements it affects.
319
+
320
+ # Decision
321
+
322
+ Describe the decision here. The leading "*" in the Status table marks the
323
+ current state of the record.
324
+
325
+ # Scope
326
+
327
+ | Item | Status | Start Date | Target Date | Description |
328
+ |---|---|---|---|---|
329
+ | Requirements | To Do | | | |
330
+ | Code | To Do | | | |
331
+ | Tests | To Do | | | |
332
+
333
+ # Out of Scope
334
+
335
+ The ADR contains the most important sections with no detailed content.
336
+
337
+ # Consequences
338
+
339
+ ## Positive
340
+
341
+ An ADR example will clearly show the approach Almirah framework use for any software changes.
342
+
343
+ ## Negative
344
+
345
+ The format of this ADR can be different that is required for the real project.
346
+
347
+ ## Neutral
348
+
349
+ TBD
350
+
351
+ # Alternatives Considered
352
+
353
+ - **Add an empty `decisions/` directory only.** Rejected: an empty folder neither teaches the format nor causes the overview page to render, so it would not exercise the feature.
354
+
355
+ # Affected Documents
356
+
357
+ Table below shows a requirement whose text this decision creates or updates.
358
+
359
+ | # | Proposed Text | Req-ID |
360
+ |---|---|---|
361
+ | 1 | This is a first requirement (controlled paragraph with ID equal to "REQ-001"). | >[REQ-001] |
362
+
363
+ # Software Versions
364
+
365
+ | Software Version Category | Software Version ID |
366
+ |---|---|
367
+ | Latest Released Version | n/a |
368
+ | Issue Found in Version | n/a |
369
+ | Target Release Version | 0.0.1 |
370
+
371
+ # References
372
+
373
+ TBD
374
+
375
+ # Review Evidences
376
+
377
+ TBD
378
+
379
+ EOS
380
+
381
+ path = File.join(path, 'adr-001-start-project-decision.md')
382
+ file = File.open(path, 'w')
383
+ file.puts file_content
384
+ file.close
385
+ end
298
386
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ # Computes the relative URL between two generated pages, each given as a path
6
+ # relative to the build root (e.g. "decisions/release 0.4.1/adr-185.html").
7
+ # Produces forward-slash separators with spaces percent-encoded. Shared by all
8
+ # internal cross-document links (ADR-186).
9
+ module RelativeUrl
10
+ module_function
11
+
12
+ def between(from_output_rel, to_output_rel, fragment: nil)
13
+ from_dir = Pathname.new(from_output_rel).dirname
14
+ rel = Pathname.new(to_output_rel).relative_path_from(from_dir).to_s
15
+ url = rel.split('/').map { |segment| encode_segment(segment) }.join('/')
16
+ fragment && !fragment.empty? ? "#{url}##{fragment}" : url
17
+ end
18
+
19
+ def encode_segment(segment)
20
+ segment.gsub(' ', '%20')
21
+ end
22
+ end
@@ -16,7 +16,7 @@ class SpecificationsDb
16
16
  @specifications.each do |sp|
17
17
  sp.items.each do |i|
18
18
  if (i.instance_of? Paragraph) or (i.instance_of? ControlledParagraph)
19
- e = { 'document' => i.parent_doc.title, \
19
+ e = { 'doc_title' => i.parent_doc.title, \
20
20
  'doc_color' => i.parent_doc.color, \
21
21
  'text' => i.text, \
22
22
  'heading_url' => i.parent_heading.get_url, \
@@ -36,7 +36,7 @@ class SpecificationsDb
36
36
  item_to_process.rows.each do |r|
37
37
  if r.is_a?(MarkdownList)
38
38
  f_text = r.text
39
- e = { 'document' => item_for_reference.parent_doc.title, \
39
+ e = { 'doc_title' => item_for_reference.parent_doc.title, \
40
40
  'doc_color' => item_for_reference.parent_doc.color, \
41
41
  'text' => f_text, \
42
42
  'heading_url' => item_for_reference.parent_heading.get_url, \
@@ -45,7 +45,7 @@ class SpecificationsDb
45
45
  add_markdown_list_item_to_db(data, item_for_reference, r)
46
46
  else
47
47
  f_text = r
48
- e = { 'document' => item_for_reference.parent_doc.title, \
48
+ e = { 'doc_title' => item_for_reference.parent_doc.title, \
49
49
  'doc_color' => item_for_reference.parent_doc.color, \
50
50
  'text' => f_text, \
51
51
  'heading_url' => item_for_reference.parent_heading.get_url, \
@@ -61,7 +61,7 @@ class SpecificationsDb
61
61
  item_to_process.rows.each do |row|
62
62
  table_text += "| #{row.join(' | ')} |"
63
63
  end
64
- e = { 'document' => item_for_reference.parent_doc.title, \
64
+ e = { 'doc_title' => item_for_reference.parent_doc.title, \
65
65
  'doc_color' => item_for_reference.parent_doc.color, \
66
66
  'text' => table_text, \
67
67
  'heading_url' => item_for_reference.parent_heading.get_url, \
@@ -154,6 +154,11 @@ a:active {
154
154
  text-decoration: none;
155
155
  display: inline-block;
156
156
  }
157
+ a.broken_link, a.broken_link:link, a.broken_link:visited, span.broken_link {
158
+ color: #c00;
159
+ text-decoration: underline wavy #c00;
160
+ cursor: help;
161
+ }
157
162
  div.blockquote {
158
163
  display: block;
159
164
  background:#f9f9fb;
@@ -1,5 +1,5 @@
1
1
  // Do search only on the Index Page
2
- import { create, search, insert } from 'https://unpkg.com/@orama/orama@latest/dist/index.js'
2
+ import { create, search, insert } from 'https://unpkg.com/@orama/orama@3.1.18/dist/browser/index.js'
3
3
 
4
4
  // Create DB
5
5
  const db = await create({
@@ -19,7 +19,7 @@ const data_rows = await response.json();
19
19
  let i = 0;
20
20
  while (i < data_rows.length) {
21
21
  await insert(db, {
22
- document: data_rows[i]["document"],
22
+ doc_title: data_rows[i]["doc_title"],
23
23
  doc_color: data_rows[i]["doc_color"],
24
24
  text: data_rows[i]["text"],
25
25
  heading_url: data_rows[i]["heading_url"],
@@ -66,7 +66,7 @@ async function search_onKeyUp(){
66
66
  }else{
67
67
 
68
68
  searchResult.hits.forEach ((value, index, array) =>{
69
- const doc_title = value.document["document"]
69
+ const doc_title = value.document["doc_title"]
70
70
  const doc_color = value.document["doc_color"]
71
71
  const heading_url = value.document["heading_url"]
72
72
  const heading_text = value.document["heading_text"]
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.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleksandr Ivanov
@@ -58,6 +58,7 @@ extra_rdoc_files: []
58
58
  files:
59
59
  - bin/almirah
60
60
  - lib/almirah.rb
61
+ - lib/almirah/console_reporter.rb
61
62
  - lib/almirah/doc_fabric.rb
62
63
  - lib/almirah/doc_items/blockquote.rb
63
64
  - lib/almirah/doc_items/code_block.rb
@@ -89,6 +90,7 @@ files:
89
90
  - lib/almirah/doc_types/traceability.rb
90
91
  - lib/almirah/dom/doc_section.rb
91
92
  - lib/almirah/dom/document.rb
93
+ - lib/almirah/link_registry.rb
92
94
  - lib/almirah/navigation_pane.rb
93
95
  - lib/almirah/project.rb
94
96
  - lib/almirah/project/doc_linker.rb
@@ -96,6 +98,7 @@ files:
96
98
  - lib/almirah/project_configuration.rb
97
99
  - lib/almirah/project_template.rb
98
100
  - lib/almirah/project_utility.rb
101
+ - lib/almirah/relative_url.rb
99
102
  - lib/almirah/search/specifications_db.rb
100
103
  - lib/almirah/source_file_parser.rb
101
104
  - lib/almirah/templates/css/main.css