Almirah 0.4.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b53b0b8a1c024c618e1fff5aac5288312b51b2a06f228527f06183b31f114ac
4
- data.tar.gz: 41959a8eaba117737d913afd63bda6947301e3a5a84aac96b94030f43f6a4564
3
+ metadata.gz: e7ecb1c0f1633226eaccd34b70d7c45c6d00e6f8ec4b605231f71b7bea725dcd
4
+ data.tar.gz: a223709af5754031b0efef8c443fd158fad6c2c3fde08fbba83233e82075d82a
5
5
  SHA512:
6
- metadata.gz: 66edfea3c57bb86b6cc5e63de0b63579ec756a12b2704db2c86d2956124e7a9a89fd6e1db86f5481c18840bb4378acbb11d1a5db250d14ccfc7a699c7e0f614e
7
- data.tar.gz: ddbd78d3135822cf967a6fac7c2ae87986dbf79aa537b403dfc15c41ffbc36c7594c9460cb086b067d70b7492aee4f0bc81c48b40b07caf02ea70b22fffd915b
6
+ metadata.gz: 4d57afaec80f6e7370aec60e254f6650832ed2808a2b1550efc7a5271f69bd4e2bbce2e636eb2f7128b1a8ccee6e2bf978e0ca032ad275c399bbc334c12c57ab
7
+ data.tar.gz: a5e6de48b94a5de8a77ab8cb7e744116b3c9ecb5b92e3f81d1231c9436e412d9f1e53e97f2db9c2dc733f3dfdda6966e305b3b7faf9963453d1abff970358985
@@ -10,7 +10,6 @@ require_relative 'doc_types/risk_record'
10
10
  require_relative 'doc_types/risk_registry_page'
11
11
  require_relative 'doc_types/risks_overview'
12
12
  require_relative 'doc_types/decisions_overview'
13
- require_relative 'doc_types/critical_chain_page'
14
13
  require_relative 'doc_parser'
15
14
  require_relative 'source_file_parser'
16
15
  require_relative 'dom/document'
@@ -72,7 +71,6 @@ class DocFabric
72
71
  doc.extract_target_date
73
72
  doc.extract_target_release_version
74
73
  doc.extract_owners
75
- doc.extract_scope_table
76
74
  doc
77
75
  end
78
76
 
@@ -95,10 +93,6 @@ class DocFabric
95
93
  DecisionsOverview.new project
96
94
  end
97
95
 
98
- def self.create_critical_chain_page(project)
99
- CriticalChainPage.new project
100
- end
101
-
102
96
  def self.create_source_file(repository_path, path, repository_name)
103
97
  doc = SourceFile.new repository_path, path, repository_name
104
98
  DocFabric.parse_source_file doc
@@ -1,20 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'date'
4
3
  require_relative 'controlled_table'
5
- require_relative 'work_item'
4
+ require_relative '../relative_url'
6
5
 
7
6
  # Purpose-built controlled table for a Decision Record's `# Scope` section
8
- # (ADR-194). It is modelled on ControlledTable but addresses its columns by
9
- # header text (not position) and has no fixed link column: it locates `#`,
10
- # `Item`, `Owner`, `Depends On`, and `Status` wherever the author placed them.
7
+ # (ADR-210, slimmed to presentation only by ADR-222). It is modelled on
8
+ # ControlledTable but addresses its columns by header text (not position): it
9
+ # locates `#` and `Depends On` wherever the author placed them, along with the
10
+ # date columns whose cells are kept on one line (ENH-214).
11
11
  #
12
- # Each data row becomes a WorkItem (the dependency-network node). The table also
13
- # keeps a plain header-addressed cell grid (`cells`) so the ADR-193 Scope
14
- # readers (owners / WIP / dates) keep working unchanged after the Scope section
15
- # stopped being a plain MarkdownTable.
12
+ # Rendering is the table's whole job: the `#` column is an anchored row number
13
+ # so individual Scope rows can be linked, and each Depends On reference renders
14
+ # as a clickable link to the referenced record.
16
15
  class ScopeTable < ControlledTable
17
- attr_reader :cells, :work_items
16
+ attr_reader :cells
18
17
 
19
18
  def initialize(doc, markdown_table)
20
19
  @parent_doc = doc
@@ -22,7 +21,7 @@ class ScopeTable < ControlledTable
22
21
  @column_names = markdown_table.column_names
23
22
  @is_separator_detected = markdown_table.is_separator_detected
24
23
  @cells = []
25
- @work_items = []
24
+ @rows = []
26
25
  @col = locate_columns(@column_names)
27
26
  # The switch to ScopeTable happens on the first data row, so the source
28
27
  # MarkdownTable carries none yet; any it did are re-read for safety.
@@ -34,12 +33,6 @@ class ScopeTable < ControlledTable
34
33
  true
35
34
  end
36
35
 
37
- # True when the table has a leading `#` step column, hence a per-row anchor a
38
- # dependent record can deep-link to (ADR-194).
39
- def step_column?
40
- !@col[:step].nil?
41
- end
42
-
43
36
  # Render as a controlled table: an anchored, centered step number in the `#`
44
37
  # column (so rows are linkable, like the protocol table) and formatted text
45
38
  # for every other cell.
@@ -52,7 +45,7 @@ class ScopeTable < ControlledTable
52
45
  s << "<table class=\"markdown_table\">\n\t<thead>"
53
46
  @column_names.each { |h| s << " <th>#{format_string(h.strip)}</th>" }
54
47
  s << " </thead>\n"
55
- @cells.each_with_index { |cells, i| s << row_html(cells, @work_items[i]) }
48
+ @cells.each_with_index { |cells, i| s << row_html(cells, @rows[i]) }
56
49
  s << "</table>\n"
57
50
  s
58
51
  end
@@ -64,58 +57,14 @@ class ScopeTable < ControlledTable
64
57
  column_names.each_with_index { |h, i| return i if h.to_s.strip == name }
65
58
  nil
66
59
  end
67
- { step: index_of.call('#'), item: index_of.call('Item'), owner: index_of.call('Owner'),
68
- depends_on: index_of.call('Depends On'), status: index_of.call('Status'),
69
- est_focused: index_of.call('Est (focused)'), est_safe: index_of.call('Est (safe)'),
60
+ { step: index_of.call('#'), depends_on: index_of.call('Depends On'),
70
61
  start_date: index_of.call('Start Date'), target_date: index_of.call('Target Date') }
71
62
  end
72
63
 
73
64
  def append_cells(cells)
74
65
  @cells << cells
75
- @work_items << build_work_item(cells)
76
- end
77
-
78
- def build_work_item(cells)
79
- WorkItem.new(
80
- record_id: @parent_doc.id,
81
- step: step_number(cells, @cells.length), # 1-based row order fallback
82
- activity: cell(cells, @col[:item]),
83
- owner: cell(cells, @col[:owner]),
84
- status: cell(cells, @col[:status]),
85
- depends_on_refs: parse_depends_on(cell(cells, @col[:depends_on])),
86
- start_date: parse_date(cell(cells, @col[:start_date])),
87
- target_date: parse_date(cell(cells, @col[:target_date])),
88
- **estimate_attrs(cells)
89
- )
90
- end
91
-
92
- # A Scope Start/Target Date cell as a Date (ADR-213), or nil when blank or not
93
- # in the DD-MM-YYYY form the rest of the planning views read (ADR-205).
94
- def parse_date(text)
95
- match = /\A(\d{2})-(\d{2})-(\d{4})\z/.match(text.to_s.strip)
96
- return nil unless match
97
-
98
- Date.new(match[3].to_i, match[2].to_i, match[1].to_i)
99
- rescue ArgumentError
100
- nil
101
- end
102
-
103
- # The ADR-195 estimate-derived attributes: focused and safe working-day
104
- # estimates (blank/unparseable -> 0) and the owning record's sequence number
105
- # (the critical-chain priority tiebreak).
106
- def estimate_attrs(cells)
107
- {
108
- focused_estimate: parse_estimate(cell(cells, @col[:est_focused])),
109
- safe_estimate: parse_estimate(cell(cells, @col[:est_safe])),
110
- record_sequence: @parent_doc.sequence_number.to_i
111
- }
112
- end
113
-
114
- # A Scope estimate cell as a non-negative number of working days (decimals
115
- # allowed); blank, unparseable, or negative values count as 0 (ADR-195).
116
- def parse_estimate(text)
117
- value = Float(text.to_s.strip, exception: false)
118
- value&.positive? ? value : 0.0
66
+ @rows << { step: step_number(cells, @cells.length),
67
+ depends_on: parse_depends_on(cell(cells, @col[:depends_on])) }
119
68
  end
120
69
 
121
70
  # The `#` column's integer when present; otherwise the row's 1-based position,
@@ -137,52 +86,54 @@ class ScopeTable < ControlledTable
137
86
  cells[index].to_s.strip
138
87
  end
139
88
 
140
- def row_html(cells, work_item)
89
+ def row_html(cells, row)
141
90
  s = +"\t<tr>\n"
142
- cells.each_with_index { |value, index| s << cell_html(value, index, work_item) }
91
+ cells.each_with_index { |value, index| s << cell_html(value, index, row) }
143
92
  s << "\t</tr>\n"
144
93
  s
145
94
  end
146
95
 
147
- def cell_html(value, index, work_item)
148
- return depends_on_html(work_item) if index == @col[:depends_on]
149
- return step_cell_html(value, work_item) if index == @col[:step]
96
+ def cell_html(value, index, row)
97
+ return depends_on_html(row) if index == @col[:depends_on]
98
+ return step_cell_html(value, row) if index == @col[:step]
150
99
  return date_cell_html(value) if [@col[:start_date], @col[:target_date]].include?(index)
151
100
 
152
101
  "\t\t<td>#{format_string(value.strip)}</td>\n"
153
102
  end
154
103
 
155
104
  # The Start/Target Date cells are kept on one line so a DD-MM-YYYY date never
156
- # wraps at its hyphens in a narrow column (ADR-213).
105
+ # wraps at its hyphens in a narrow column (ENH-214).
157
106
  def date_cell_html(value)
158
107
  "\t\t<td class=\"scope_date\">#{format_string(value.strip)}</td>\n"
159
108
  end
160
109
 
161
110
  # The `#` cell is a named anchor (id + name), mirroring the test-step number
162
- # column, so a dependent record's Depends On link can target this row.
163
- def step_cell_html(value, work_item)
164
- anchor = work_item.row_anchor
111
+ # column, so a Scope row can be deep-linked. Namespaced with `.scope.` so it
112
+ # never collides with the Affected Documents controlled table, whose rows
113
+ # anchor on the same `<record>.<step>` scheme on the same page.
114
+ def step_cell_html(value, row)
115
+ anchor = "#{@parent_doc.id}.scope.#{row[:step]}"
165
116
  "\t\t<td style=\"text-align: center;\">" \
166
117
  "<a name=\"#{anchor}\" id=\"#{anchor}\" href=\"##{anchor}\">#{format_string(value.strip)}</a></td>\n"
167
118
  end
168
119
 
169
- # Render each authored Depends On reference as a link to the resolved target
170
- # record. When the target record has a `#` step column, the link deep-jumps to
171
- # the aligned activity row (its step anchor); otherwise it opens the record
172
- # page (no such anchor exists). Unresolved references render as broken-link
173
- # spans (ADR-194 also reports them on the console).
174
- def depends_on_html(work_item)
175
- return "\t\t<td></td>\n" if work_item.depends_on_refs.empty?
120
+ # Render each authored Depends On reference as a link to the referenced
121
+ # record's page, resolved against the project-wide link registry. Unresolved
122
+ # references render as broken-link spans.
123
+ def depends_on_html(row)
124
+ return "\t\t<td></td>\n" if row[:depends_on].empty?
176
125
 
177
- links = work_item.depends_on_refs.map { |ref| dependency_ref_html(ref, work_item) }
126
+ links = row[:depends_on].map { |ref| dependency_ref_html(ref) }
178
127
  "\t\t<td>#{links.join(', ')}</td>\n"
179
128
  end
180
129
 
181
- def dependency_ref_html(ref, work_item)
182
- dep = work_item.resolved_dependencies[ref]
183
- return "<span class=\"broken_link\" title=\"Unresolved Depends On\">#{ref}</span>" if dep.nil?
184
-
185
- href = RelativeUrl.between(@parent_doc.output_rel_path, dep[:doc].output_rel_path, fragment: dep[:anchor])
186
- "<a href=\"#{href}\" class=\"external\" title=\"Depends on #{dep[:label]}\">#{ref}</a>"
130
+ def dependency_ref_html(ref)
131
+ target = TextLine.link_registry&.find_by_id(ref)
132
+ if target.nil? || target.output_rel_path.nil?
133
+ "<span class=\"broken_link\" title=\"Unresolved Depends On\">#{ref}</span>"
134
+ else
135
+ href = RelativeUrl.between(@parent_doc.output_rel_path, target.output_rel_path)
136
+ "<a href=\"#{href}\" class=\"external\" title=\"Depends on #{target.id}\">#{ref}</a>"
137
+ end
187
138
  end
188
139
  end
@@ -35,8 +35,6 @@ class BaseDocument
35
35
  "#{@id}.html"
36
36
  elsif instance_of? DecisionsOverview
37
37
  'overview.html'
38
- elsif instance_of? CriticalChainPage
39
- 'critical-chain.html'
40
38
  elsif instance_of?(RiskRegistryPage) || instance_of?(RisksOverview)
41
39
  'overview.html'
42
40
  elsif is_a? Decision # RiskRecord included
@@ -70,10 +68,7 @@ class BaseDocument
70
68
  else
71
69
  file.puts index_link(rel_to('index.html'))
72
70
  end
73
- if BaseDocument.show_decisions_link
74
- file.puts decisions_link(rel_to('decisions/overview.html'))
75
- file.puts critical_chain_link(rel_to('decisions/critical-chain.html'))
76
- end
71
+ file.puts decisions_link(rel_to('decisions/overview.html')) if BaseDocument.show_decisions_link
77
72
  file.puts risks_link(rel_to('risks/overview.html')) if BaseDocument.show_risks_link
78
73
  elsif s.include?('{{GEM_VERSION}}')
79
74
  file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
@@ -89,11 +84,6 @@ class BaseDocument
89
84
  %(<a id="decisions_menu_item" href="#{href}">#{icon}&nbsp;Decision Records</a>)
90
85
  end
91
86
 
92
- def critical_chain_link(href)
93
- icon = '<span><i class="fa fa-link" aria-hidden="true"></i></span>'
94
- %(<a id="critical_chain_menu_item" href="#{href}">#{icon}&nbsp;Critical Chain</a>)
95
- end
96
-
97
87
  def risks_link(href)
98
88
  icon = '<span><i class="fa fa-exclamation-triangle" aria-hidden="true"></i></span>'
99
89
  %(<a id="risks_menu_item" href="#{href}">#{icon}&nbsp;Risks</a>)
@@ -9,7 +9,7 @@ require_relative '../doc_items/scope_table'
9
9
  class Decision < PersistentDocument
10
10
  attr_accessor :path, :sequence_number, :record_type, :html_rel_path, :root_prefix, :current_status,
11
11
  :start_date, :target_date, :target_release_version, :specifications_path, :wrong_links_hash,
12
- :owners, :scope_table
12
+ :owners
13
13
 
14
14
  def initialize(file_path)
15
15
  super
@@ -84,55 +84,6 @@ class Decision < PersistentDocument
84
84
  end
85
85
  end
86
86
 
87
- # The parsed Scope ScopeTable (ADR-194), or nil when the record has no Scope
88
- # section. Memoised so the work-item network and the readers share one table.
89
- def extract_scope_table
90
- table = find_section_table('Scope')
91
- @scope_table = table.is_a?(ScopeTable) ? table : nil
92
- end
93
-
94
- # The Scope rows as WorkItem nodes (ADR-194); empty when there is no ScopeTable.
95
- def scope_work_items
96
- @scope_table ? @scope_table.work_items : []
97
- end
98
-
99
- # A record declares prerequisites when any of its rows carries a Depends On
100
- # reference; the overview Kit column is empty otherwise.
101
- def declared_dependencies?
102
- scope_work_items.any? { |wi| wi.depends_on_refs.any? }
103
- end
104
-
105
- # Kitted when every one of its work items is (a record with no rows is kitted).
106
- def fully_kitted?
107
- scope_work_items.all?(&:fully_kitted?)
108
- end
109
-
110
- # A started row blocked by an unsatisfied cross-record predecessor — the
111
- # overview emphasises such a record, matching the console warning.
112
- def kit_started_violation?
113
- scope_work_items.any?(&:cross_record_violation?)
114
- end
115
-
116
- # One entry per Scope row whose row Status is In-Progress, yielding that row's
117
- # owner. Rows without an owner, or not In-Progress, contribute nothing. The
118
- # per-row Status is read directly and is independent of the record's lifecycle
119
- # status. Owner and Status columns are located by header text, not position.
120
- def in_progress_owner_tally
121
- table = find_section_table('Scope')
122
- return [] if table.nil?
123
-
124
- owner_idx = column_index(table, 'Owner')
125
- status_idx = column_index(table, 'Status')
126
- return [] if owner_idx.nil? || status_idx.nil?
127
-
128
- table.cells.filter_map do |row|
129
- owner = row[owner_idx].to_s.strip
130
- next if owner.empty?
131
-
132
- owner if row[status_idx].to_s.strip == 'In-Progress'
133
- end
134
- end
135
-
136
87
  def effective_status_on(date)
137
88
  table = find_section_table('Status')
138
89
  return nil if table.nil?
@@ -158,43 +109,6 @@ class Decision < PersistentDocument
158
109
  status.empty? ? nil : status
159
110
  end
160
111
 
161
- # Total logged effort in hours across the # Effort table (ADR-196); 0 when the
162
- # record has no Effort section. Undated and unparseable entries still count
163
- # toward the record total.
164
- def actual_hours
165
- effort_entries.sum { |e| e[:hours] }
166
- end
167
-
168
- # Logged hours dated on or before `date` (ADR-196). Undated entries are
169
- # excluded since they cannot be placed on the timeline.
170
- def actual_hours_on(date)
171
- effort_entries.sum { |e| e[:date] && e[:date] <= date ? e[:hours] : 0.0 }
172
- end
173
-
174
- # Logged hours dated on or before `date` for the Scope row whose Item matches
175
- # `item` (case-insensitive) — the per-chain-row actual the fever chart credits
176
- # (ADR-196). Entries with no Item credit no row and are excluded here.
177
- def row_actual_hours_on(item, date)
178
- key = item.to_s.strip.downcase
179
- return 0.0 if key.empty?
180
-
181
- effort_entries.sum do |e|
182
- e[:item] == key && e[:date] && e[:date] <= date ? e[:hours] : 0.0
183
- end
184
- end
185
-
186
- # The [earliest, latest] dated # Effort entry crediting the Scope row whose Item
187
- # matches `item` (case-insensitive) — the real logged span the Gantt's tracking
188
- # lane draws (ADR-213). nil when the row has no dated effort. Undated entries are
189
- # excluded since they cannot be placed on the timeline.
190
- def effort_date_range(item)
191
- key = item.to_s.strip.downcase
192
- return nil if key.empty?
193
-
194
- dates = effort_entries.filter_map { |e| e[:date] if e[:item] == key }
195
- dates.empty? ? nil : dates.minmax
196
- end
197
-
198
112
  private
199
113
 
200
114
  def lookup_cell(section_name:, key_column:, value_column:, key:)
@@ -258,45 +172,6 @@ class Decision < PersistentDocument
258
172
  nil
259
173
  end
260
174
 
261
- # Parsed # Effort rows (ADR-196): [{ date:, item:, hours: }, ...]. The section
262
- # is located by heading text and its columns addressed by header (Date / Item /
263
- # Hours), like Status and Scope. Hours parse as non-negative floats (blank,
264
- # negative, or unparseable -> 0); Item is downcased for case-insensitive row
265
- # matching, or nil when absent. Memoised so the readers share one parse.
266
- def effort_entries
267
- return @effort_entries if defined?(@effort_entries)
268
-
269
- @effort_entries = parse_effort_entries
270
- end
271
-
272
- def parse_effort_entries
273
- table = find_section_table('Effort')
274
- return [] if table.nil?
275
-
276
- date_idx = column_index(table, 'Date')
277
- hours_idx = column_index(table, 'Hours')
278
- return [] if date_idx.nil? || hours_idx.nil?
279
-
280
- item_idx = column_index(table, 'Item')
281
- table.cells.map { |row| effort_entry(row, date_idx, item_idx, hours_idx) }
282
- end
283
-
284
- def effort_entry(row, date_idx, item_idx, hours_idx)
285
- { date: parse_dd_mm_yyyy(row[date_idx]),
286
- item: item_idx ? normalize_item(row[item_idx]) : nil,
287
- hours: parse_hours(row[hours_idx]) }
288
- end
289
-
290
- def normalize_item(value)
291
- text = value.to_s.strip.downcase
292
- text.empty? ? nil : text
293
- end
294
-
295
- def parse_hours(value)
296
- hours = Float(value.to_s.strip, exception: false)
297
- hours.nil? || hours.negative? ? 0.0 : hours
298
- end
299
-
300
175
  def assign_id_parts(stem)
301
176
  match = stem.match(/\A([A-Za-z]+)-(\d+)/)
302
177
  if match