commenter 0.3.0 → 0.4.0

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/CLAUDE.md +17 -7
  4. data/CONTEXT.md +27 -0
  5. data/README.adoc +92 -17
  6. data/commenter.gemspec +1 -0
  7. data/lib/commenter/ballot.rb +67 -0
  8. data/lib/commenter/ballot_report.rb +60 -0
  9. data/lib/commenter/cli.rb +62 -43
  10. data/lib/commenter/comment.rb +103 -126
  11. data/lib/commenter/comment_locality.rb +18 -0
  12. data/lib/commenter/comment_sheet.rb +31 -63
  13. data/lib/commenter/comment_type.rb +39 -0
  14. data/lib/commenter/disposition_status.rb +37 -0
  15. data/lib/commenter/filler.rb +91 -59
  16. data/lib/commenter/github_info.rb +23 -0
  17. data/lib/commenter/github_integration.rb +17 -71
  18. data/lib/commenter/github_session.rb +35 -0
  19. data/lib/commenter/parser/osd_xlsx_parser.rb +1 -12
  20. data/lib/commenter/parser/track_change_docx_parser.rb +330 -0
  21. data/lib/commenter/parser.rb +10 -13
  22. data/lib/commenter/version.rb +1 -1
  23. data/lib/commenter.rb +16 -5
  24. data/spec/commenter/ballot_report_spec.rb +62 -0
  25. data/spec/commenter/ballot_spec.rb +68 -0
  26. data/spec/commenter/cli_spec.rb +41 -0
  27. data/spec/commenter/comment_sheet_spec.rb +35 -10
  28. data/spec/commenter/comment_spec.rb +22 -9
  29. data/spec/commenter/comment_type_spec.rb +45 -0
  30. data/spec/commenter/disposition_status_spec.rb +40 -0
  31. data/spec/commenter/filler_spec.rb +119 -0
  32. data/spec/commenter/github_integration_spec.rb +65 -32
  33. data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
  34. data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
  35. data/spec/support/redline_docx_builder.rb +95 -0
  36. data/spec/support/xlsx_builder.rb +17 -12
  37. metadata +32 -2
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "nokogiri"
5
+
6
+ module Commenter
7
+ class Parser
8
+ # Extracts tracked changes from a redlined Word document into an ISO
9
+ # 2012-03 comment sheet.
10
+ #
11
+ # Captured track changes (w:ins, w:del, w:moveFrom, w:moveTo):
12
+ # - proposed_change renders the change itself, e.g. +Insert: "text"+
13
+ # - locality.clause is resolved from the nearest preceding heading
14
+ # - locality.element is resolved from caption/inline references
15
+ # (Table N, Figure N, Formula (N), NOTE n) within the current clause
16
+ # - observations can be stamped via the observations or accept_all options
17
+ #
18
+ # Reviewer comment threads (word/comments.xml w:comment) are emitted
19
+ # after the track changes with -CNNN ids: the remark verbatim in
20
+ # comments, its instruction reworded as the proposed change, and empty
21
+ # observations for the owner to draft.
22
+ #
23
+ # Self-closing markers (paragraph-mark insertions inside rPr) carry no
24
+ # content and are skipped.
25
+ #
26
+ # word/document.xml is streamed with Nokogiri::XML::Reader because redline
27
+ # documents can exceed 100 MB.
28
+ class TrackChangeDocxParser
29
+ W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
30
+
31
+ HEADING_STYLE = /\A(?:Heading[1-6]|h[2-5]annex\d*|ANNEX|BaseHeading)\z/i
32
+ KIND_COMMENT = { "ins" => "insertion", "del" => "deletion", "moveFrom" => "move (from)",
33
+ "moveTo" => "move (to)" }.freeze
34
+ KIND_LABEL = { "ins" => "Insert", "del" => "Delete", "moveFrom" => "Move from",
35
+ "moveTo" => "Move to" }.freeze
36
+
37
+ DEFAULT_BODY = "CS"
38
+ WHOLE_DOCUMENT = "_whole document"
39
+ ACCEPT_ALL = "Accepted. Tracked change accepted."
40
+
41
+ attr_reader :skipped_markers
42
+
43
+ def parse(path, options = {})
44
+ body = options[:body] || DEFAULT_BODY
45
+ changes = []
46
+ remarks = {}
47
+ anchors = {}
48
+ @skipped_markers = 0
49
+
50
+ Zip::File.open(path) do |zip|
51
+ entry = zip.glob("word/document.xml").first
52
+ raise Commenter::Error, "word/document.xml not found in #{path}" unless entry
53
+
54
+ remarks = read_remarks(zip)
55
+ changes = entry.get_input_stream { |stream| stream_changes(stream, anchors) }
56
+ end
57
+
58
+ CommentSheet.new(
59
+ version: "2012-03",
60
+ date: sheet_date(changes),
61
+ document: options[:document],
62
+ stage: options[:stage],
63
+ comments: build_change_comments(changes, body, options) +
64
+ build_remark_comments(remarks, anchors, body, options)
65
+ )
66
+ end
67
+
68
+ # Turns a reviewer's remark phrased as a request ("Please remove this
69
+ # NOTE...") into the corresponding proposed change ("Remove this
70
+ # NOTE...").
71
+ def self.reword_remark(text)
72
+ text.to_s.strip.sub(/\APlease\s+/i, "").sub(/\A[a-z]/, &:upcase)
73
+ end
74
+
75
+ private
76
+
77
+ def read_remarks(zip)
78
+ entry = zip.glob("word/comments.xml").first
79
+ return {} unless entry
80
+
81
+ document = entry.get_input_stream { |stream| Nokogiri::XML(stream) }
82
+ document.xpath("//w:comment", "w" => W_NS).each_with_object({}) do |node, remarks|
83
+ remarks[node["w:id"]] = {
84
+ author: node["w:author"],
85
+ date: node["w:date"],
86
+ text: node.xpath(".//w:t", "w" => W_NS).map(&:text).join
87
+ }
88
+ end
89
+ end
90
+
91
+ def sheet_date(changes)
92
+ dates = changes.filter_map { |change| change[:date].to_s[/\A\d{4}-\d{2}-\d{2}/] }
93
+ dates.max
94
+ end
95
+
96
+ def build_change_comments(changes, body, options)
97
+ changes.each_with_index.map do |change, index|
98
+ kind = change[:kind]
99
+ Comment.new(
100
+ id: format("%<body>s-%03<index>d", body: body, index: index + 1),
101
+ body: body,
102
+ locality: { clause: change[:clause], line_number: nil, element: change[:element] },
103
+ type: CommentType.full_name(options[:type] || "te"),
104
+ comments: "Track change (#{KIND_COMMENT.fetch(kind, kind)})",
105
+ proposed_change: "#{KIND_LABEL.fetch(kind, kind)}: \"#{change[:text].strip}\"",
106
+ observations: observations_for(options)
107
+ )
108
+ end
109
+ end
110
+
111
+ def build_remark_comments(remarks, anchors, body, options)
112
+ remarks.keys.sort_by(&:to_i).each_with_index.map do |remark_id, index|
113
+ remark = remarks[remark_id]
114
+ anchor = anchors[remark_id] || {}
115
+ clause = anchor[:clause].to_s.empty? ? WHOLE_DOCUMENT : anchor[:clause]
116
+ Comment.new(
117
+ id: format("%<body>s-C%03<index>d", body: body, index: index + 1),
118
+ body: body,
119
+ locality: { clause: clause, line_number: nil, element: anchor[:element] },
120
+ type: options[:remark_type] || "ed",
121
+ comments: remark[:text],
122
+ proposed_change: self.class.reword_remark(remark[:text]),
123
+ observations: nil
124
+ )
125
+ end
126
+ end
127
+
128
+ def observations_for(options)
129
+ return nil if options[:exclude_observations]
130
+
131
+ options[:observations] || (options[:accept_all] ? ACCEPT_ALL : nil)
132
+ end
133
+
134
+ def stream_changes(io, anchors = {})
135
+ changes = []
136
+ change_stack = []
137
+ paragraph = ParagraphState.new
138
+
139
+ Nokogiri::XML::Reader(io).each do |reader|
140
+ case reader.node_type
141
+ when Nokogiri::XML::Reader::TYPE_ELEMENT
142
+ handle_start(reader, change_stack, paragraph, anchors)
143
+ when Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE
144
+ handle_text(reader, change_stack, paragraph)
145
+ when Nokogiri::XML::Reader::TYPE_END_ELEMENT
146
+ handle_end(reader, change_stack, paragraph, changes)
147
+ end
148
+ end
149
+
150
+ changes
151
+ end
152
+
153
+ def handle_start(reader, change_stack, paragraph, anchors = {})
154
+ case reader.local_name
155
+ when "p"
156
+ paragraph.begin_paragraph
157
+ when "pStyle"
158
+ paragraph.style = reader.attribute("w:val") || reader.attribute("val")
159
+ when "ins", "del", "moveFrom", "moveTo"
160
+ start_change(reader, change_stack, paragraph)
161
+ when "commentRangeStart"
162
+ anchor_remark(reader, paragraph, anchors)
163
+ end
164
+ end
165
+
166
+ def anchor_remark(reader, paragraph, anchors)
167
+ id = reader.attribute("w:id")
168
+ anchors[id] = {
169
+ clause: paragraph.pending_clause || paragraph.clause,
170
+ element: paragraph.pending_element || paragraph.element
171
+ }
172
+ end
173
+
174
+ def start_change(reader, change_stack, paragraph)
175
+ if reader.empty_element?
176
+ @skipped_markers += 1
177
+ return
178
+ end
179
+
180
+ change_stack.push(
181
+ kind: reader.local_name,
182
+ id: reader.attribute("w:id"),
183
+ author: reader.attribute("w:author"),
184
+ date: reader.attribute("w:date"),
185
+ text: +"",
186
+ clause: paragraph.clause,
187
+ element: paragraph.element
188
+ )
189
+ end
190
+
191
+ def handle_text(reader, change_stack, paragraph)
192
+ if (current = change_stack.last)
193
+ current[:text] << reader.value
194
+ else
195
+ paragraph.append_text(reader.value)
196
+ end
197
+ end
198
+
199
+ def handle_end(reader, change_stack, paragraph, changes)
200
+ case reader.local_name
201
+ when "p"
202
+ paragraph.commit_paragraph
203
+ when "ins", "del", "moveFrom", "moveTo"
204
+ change = change_stack.pop
205
+ changes << finalize_change(change, paragraph) if change
206
+ end
207
+ end
208
+
209
+ def finalize_change(change, paragraph)
210
+ # A change inside a heading or caption paragraph belongs to that
211
+ # paragraph, which is not committed yet at this point.
212
+ change[:clause] = paragraph.pending_clause || change[:clause]
213
+ change[:element] = paragraph.pending_element || change[:element]
214
+ change[:clause] = WHOLE_DOCUMENT if change[:clause].to_s.empty?
215
+ change
216
+ end
217
+
218
+ # Tracks the current paragraph's style and text to resolve the locality
219
+ # context of a change:
220
+ #
221
+ # - clause: the clause number of the most recent heading paragraph; an
222
+ # unnumbered sub-heading inherits its parent heading's clause
223
+ # - element: the nearest Table/Figure/Formula/NOTE reference, scoped to
224
+ # the current clause (caption paragraphs and inline mentions both
225
+ # count)
226
+ class ParagraphState
227
+ NUMBERED_CLAUSE = /\A(?:\d+(?:\.\d+)*|Annex\s+[A-Z](?:\.\d+)*|Bibliography|Foreword|Introduction)\z/i
228
+ ELEMENT_START = /\A\s*(NOTE\s+\d+|NOTE(?=\s*[—:-])|Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*)\b/i
229
+ FORMULA = /\b(Formula\s*\(\d+(?:\.\d+)*\))/
230
+ ELEMENT_ANY = /\b(Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|NOTE\s+\d+)\b/i
231
+
232
+ attr_writer :style
233
+ attr_reader :clause, :element
234
+
235
+ def initialize
236
+ @style = nil
237
+ @text = +""
238
+ @clause = ""
239
+ @element = nil
240
+ @heading_stack = []
241
+ end
242
+
243
+ def begin_paragraph
244
+ @style = nil
245
+ @text = +""
246
+ end
247
+
248
+ def append_text(value)
249
+ @text << value
250
+ end
251
+
252
+ def pending_clause
253
+ return unless heading_paragraph?
254
+
255
+ clause = self.class.clause_for(@text)
256
+ clause unless clause.empty?
257
+ end
258
+
259
+ def pending_element
260
+ self.class.element_for(@text)
261
+ end
262
+
263
+ def commit_paragraph
264
+ if heading_paragraph?
265
+ commit_heading
266
+ else
267
+ @element = self.class.element_for(@text) || @element
268
+ end
269
+ end
270
+
271
+ def heading_paragraph?
272
+ @style&.match?(HEADING_STYLE)
273
+ end
274
+
275
+ def self.numbered_clause?(clause)
276
+ clause.to_s.match?(NUMBERED_CLAUSE)
277
+ end
278
+
279
+ # Extracts the clause identifier from heading text: the leading number
280
+ # ("4.2.1 Thermodynamic ..." -> "4.2.1"), an annex reference, or a
281
+ # well-known unnumbered section. Runs may be concatenated without
282
+ # spaces ("4.2.1Thermodynamic"), so the number is matched with a
283
+ # lookahead.
284
+ def self.clause_for(text)
285
+ s = text.to_s.strip
286
+ return "" if s.empty?
287
+
288
+ return Regexp.last_match(1) if s =~ /\A\s*(Annex\s+[A-Z](?:\.\d+)*)\b/i
289
+ return Regexp.last_match(1) if s =~ /\A\s*(Bibliography|Foreword|Introduction)\b/i
290
+ return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)(?=[A-Z\s])/
291
+ return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)\b/
292
+
293
+ s
294
+ end
295
+
296
+ # Resolves the element reference of a paragraph: a caption opening the
297
+ # paragraph ("Table 3 — ...", "NOTE 2 ...", "Formula (9):") wins over an
298
+ # inline mention ("the values in Table 5 shall ...").
299
+ def self.element_for(text)
300
+ s = text.to_s
301
+ match = s.match(ELEMENT_START) || s.match(FORMULA) || s.match(ELEMENT_ANY)
302
+ match[1].squeeze(" ").strip if match
303
+ end
304
+
305
+ private
306
+
307
+ def commit_heading
308
+ level = heading_level
309
+ clause = self.class.clause_for(@text)
310
+ clause = inherited_clause(level) unless self.class.numbered_clause?(clause)
311
+ @heading_stack.pop while @heading_stack.last && @heading_stack.last[0] >= level
312
+ @heading_stack << [level, clause]
313
+ @clause = clause unless clause.empty?
314
+ # Element references belong to the clause they appear in.
315
+ @element = nil
316
+ end
317
+
318
+ def inherited_clause(level)
319
+ parent = @heading_stack.reverse.find { |(lvl, _)| lvl < level }
320
+ parent ? parent[1] : ""
321
+ end
322
+
323
+ def heading_level
324
+ m = @style.match(/\A(?:Heading|h)(\d+)/i)
325
+ m ? m[1].to_i : 1
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -2,12 +2,12 @@
2
2
 
3
3
  require "docx"
4
4
  require "pathname"
5
- require_relative "comment_sheet"
6
- require_relative "comment"
7
- require_relative "parser/osd_xlsx_parser"
8
5
 
9
6
  module Commenter
10
7
  class Parser
8
+ autoload :OsdXlsxParser, "commenter/parser/osd_xlsx_parser"
9
+ autoload :TrackChangeDocxParser, "commenter/parser/track_change_docx_parser"
10
+
11
11
  def parse(input_path, options = {})
12
12
  format = detect_format(input_path, options)
13
13
 
@@ -16,6 +16,8 @@ module Commenter
16
16
  parse_docx(input_path, options)
17
17
  when :xlsx
18
18
  parse_xlsx(input_path, options)
19
+ when :redline
20
+ parse_redline(input_path, options)
19
21
  else
20
22
  raise "Unsupported file format: #{input_path}. Supported formats: .docx, .xlsx"
21
23
  end
@@ -38,6 +40,10 @@ module Commenter
38
40
  OsdXlsxParser.new.parse(xlsx_path, options)
39
41
  end
40
42
 
43
+ def parse_redline(docx_path, options)
44
+ TrackChangeDocxParser.new.parse(docx_path, options)
45
+ end
46
+
41
47
  def parse_docx(docx_path, options)
42
48
  doc = Docx::Document.open(docx_path)
43
49
 
@@ -83,7 +89,7 @@ module Commenter
83
89
  clause: presence(cells[2]),
84
90
  element: presence(cells[3])
85
91
  },
86
- type: normalize_type(cells[4]),
92
+ type: CommentType.full_name(cells[4]),
87
93
  comments: cells[5] || "",
88
94
  proposed_change: presence(cells[6]),
89
95
  user_name: cells[0].to_s.strip
@@ -117,15 +123,6 @@ module Commenter
117
123
  value && !value.empty? ? value : nil
118
124
  end
119
125
 
120
- def normalize_type(type_str)
121
- case type_str.to_s.strip.downcase
122
- when "editorial" then "ed"
123
- when "technical" then "te"
124
- when "general" then "ge"
125
- else type_str.to_s.strip
126
- end
127
- end
128
-
129
126
  def extract_metadata(doc)
130
127
  metadata = { date: nil, document: nil, project: nil }
131
128
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/commenter.rb CHANGED
@@ -1,12 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "commenter/version"
4
- require_relative "commenter/comment"
5
- require_relative "commenter/comment_sheet"
6
- require_relative "commenter/parser"
7
- require_relative "commenter/filler"
8
- require_relative "commenter/github_integration"
9
4
 
5
+ # Commenter converts ISO comment sheets (DOCX/XLSX) to structured YAML and
6
+ # syncs comments to GitHub issues.
10
7
  module Commenter
11
8
  class Error < StandardError; end
9
+
10
+ autoload :CommentType, "commenter/comment_type"
11
+ autoload :CommentLocality, "commenter/comment_locality"
12
+ autoload :GithubInfo, "commenter/github_info"
13
+ autoload :DispositionStatus, "commenter/disposition_status"
14
+ autoload :Ballot, "commenter/ballot"
15
+ autoload :BallotReport, "commenter/ballot_report"
16
+ autoload :Comment, "commenter/comment"
17
+ autoload :CommentSheet, "commenter/comment_sheet"
18
+ autoload :GitHubSession, "commenter/github_session"
19
+ autoload :Parser, "commenter/parser"
20
+ autoload :Filler, "commenter/filler"
21
+ autoload :GitHubIssueCreator, "commenter/github_integration"
22
+ autoload :GitHubIssueRetriever, "commenter/github_integration"
12
23
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Commenter::BallotReport do
6
+ def comment(body:, observations: nil, resolution_status: nil, github: {}, type: "technical")
7
+ Commenter::Comment.new(
8
+ id: "#{body}-1", body: body, type: type,
9
+ comments: "A comment", observations: observations,
10
+ resolution_status: resolution_status, github: github
11
+ )
12
+ end
13
+
14
+ let(:sheet) do
15
+ Commenter::CommentSheet.new(comments: [
16
+ comment(body: "DE", observations: "Accepted."),
17
+ comment(body: "DE", observations: "Accept with modifications"),
18
+ comment(body: "US", observations: "Noted"),
19
+ comment(body: "US", observations: "Rejected"),
20
+ comment(body: "US"),
21
+ comment(body: "CS", resolution_status: "Not accepted"),
22
+ comment(body: "CS", github: { status: "open" })
23
+ ])
24
+ end
25
+
26
+ describe ".status_for" do
27
+ it "prefers observation text, then resolution status, then GitHub state" do
28
+ comments = sheet.comments
29
+ expect(described_class.status_for(comments[0])).to eq(:accepted)
30
+ expect(described_class.status_for(comments[4])).to eq(:undecided)
31
+ expect(described_class.status_for(comments[5])).to eq(:rejected)
32
+ expect(described_class.status_for(comments[6])).to eq(:open)
33
+ end
34
+ end
35
+
36
+ describe ".counts" do
37
+ it "counts per member body, disposition, and type" do
38
+ counts = described_class.counts(sheet)
39
+
40
+ expect(counts[:total]).to eq(7)
41
+ expect(counts[:bodies]["DE"][:total]).to eq(2)
42
+ expect(counts[:bodies]["DE"][:accepted]).to eq(1)
43
+ expect(counts[:bodies]["DE"][:accept_with_modifications]).to eq(1)
44
+ expect(counts[:bodies]["US"][:noted]).to eq(1)
45
+ expect(counts[:bodies]["US"][:rejected]).to eq(1)
46
+ expect(counts[:bodies]["US"][:undecided]).to eq(1)
47
+ expect(counts[:bodies]["CS"][:open]).to eq(1)
48
+ expect(counts[:types]["technical"]).to eq(7)
49
+ end
50
+ end
51
+
52
+ describe ".to_markdown" do
53
+ it "renders a ballot report table with totals" do
54
+ markdown = described_class.to_markdown(sheet)
55
+
56
+ expect(markdown).to start_with("| Body | Total | Accepted | AWM | Noted | Rejected | TODO | Open | Undecided |")
57
+ expect(markdown).to include("| DE | 2 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |")
58
+ expect(markdown).to include("| **Total** | **7** | **1** | **1** | **1** | **2** | **0** | **1** | **1** |")
59
+ expect(markdown).to include("Comment types: technical 7")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Commenter::Ballot do
6
+ def sheet(body:, ids:, document: nil, stage: nil)
7
+ Commenter::CommentSheet.new(
8
+ version: "2012-03",
9
+ document: document,
10
+ stage: stage,
11
+ comments: ids.map do |id|
12
+ Commenter::Comment.new(id: id, body: body, comments: "Comment #{id} from #{body}")
13
+ end
14
+ )
15
+ end
16
+
17
+ it "combines comments from all sheets in input order" do
18
+ merged = described_class.new([
19
+ sheet(body: "DE", ids: %w[DE-001 DE-002]),
20
+ sheet(body: "US", ids: %w[US-001])
21
+ ]).merge
22
+
23
+ expect(merged.comments.map(&:id)).to eq(%w[DE-001 DE-002 US-001])
24
+ expect(merged.comments.map(&:body)).to eq(%w[DE DE US])
25
+ end
26
+
27
+ it "takes metadata from the first sheet that provides it" do
28
+ merged = described_class.new([
29
+ sheet(body: "DE", ids: %w[DE-001], stage: nil),
30
+ sheet(body: "US", ids: %w[US-001], document: "ISO 2533:2026", stage: "DIS")
31
+ ]).merge
32
+
33
+ expect(merged.version).to eq("2012-03")
34
+ expect(merged.document).to eq("ISO 2533:2026")
35
+ expect(merged.stage).to eq("DIS")
36
+ end
37
+
38
+ it "silently deduplicates identical comments sharing an ID" do
39
+ duplicate = Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Same text", proposed_change: "Same change")
40
+
41
+ merged = described_class.new([
42
+ Commenter::CommentSheet.new(comments: [duplicate]),
43
+ Commenter::CommentSheet.new(comments: [
44
+ Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Same text",
45
+ proposed_change: "Same change")
46
+ ])
47
+ ]).merge
48
+
49
+ expect(merged.comments.map(&:id)).to eq(["DE-001"])
50
+ end
51
+
52
+ it "raises when the same ID carries differing content" do
53
+ conflicting = Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Different text entirely")
54
+
55
+ expect do
56
+ described_class.new([
57
+ sheet(body: "DE", ids: %w[DE-001]),
58
+ Commenter::CommentSheet.new(comments: [conflicting])
59
+ ]).merge
60
+ end.to raise_error(Commenter::Error, /DE-001/)
61
+ end
62
+
63
+ it "returns an empty sheet when no input sheets are given" do
64
+ merged = described_class.new([]).merge
65
+
66
+ expect(merged.comments).to eq([])
67
+ end
68
+ end
@@ -32,4 +32,45 @@ RSpec.describe Commenter::Cli do
32
32
  end
33
33
  end
34
34
  end
35
+
36
+ describe "#merge" do
37
+ it "writes a combined sheet with schema reference" do
38
+ Dir.mktmpdir do |dir|
39
+ one = File.join(dir, "de.yaml")
40
+ two = File.join(dir, "us.yaml")
41
+ File.write(one, { "version" => "2012-03", "comments" => [
42
+ { "id" => "DE-001", "body" => "DE", "comments" => "First" }
43
+ ] }.to_yaml)
44
+ File.write(two, { "comments" => [
45
+ { "id" => "US-001", "body" => "US", "comments" => "Second" }
46
+ ] }.to_yaml)
47
+ output = File.join(dir, "out", "merged.yaml")
48
+ schema_dir = File.join(dir, "out", "schema")
49
+
50
+ described_class.start(["merge", one, two, "--output", output, "--schema-dir", schema_dir])
51
+
52
+ data = YAML.safe_load_file(output)
53
+ expect(data["comments"].map { |comment| comment["id"] }).to eq(%w[DE-001 US-001])
54
+ expect(File).to exist(File.join(schema_dir, "iso_comment_2012-03.yaml"))
55
+ end
56
+ end
57
+ end
58
+
59
+ describe "#stats" do
60
+ it "prints a markdown disposition report" do
61
+ Dir.mktmpdir do |dir|
62
+ input = File.join(dir, "comments.yaml")
63
+ File.write(input, { "comments" => [
64
+ { "id" => "DE-001", "body" => "DE", "comments" => "A", "observations" => "Accepted." },
65
+ { "id" => "US-001", "body" => "US", "comments" => "B", "observations" => "Noted" }
66
+ ] }.to_yaml)
67
+
68
+ expect { described_class.start(["stats", input]) }
69
+ .to output(a_string_including(
70
+ "| DE | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |",
71
+ "| US | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |"
72
+ )).to_stdout
73
+ end
74
+ end
75
+ end
35
76
  end
@@ -115,21 +115,21 @@ RSpec.describe Commenter::CommentSheet do
115
115
 
116
116
  hash = sheet.to_h
117
117
 
118
- expect(hash[:version]).to eq("2012-03")
119
- expect(hash[:date]).to eq("2024-06-04")
120
- expect(hash[:document]).to eq("Test Document")
121
- expect(hash[:project]).to eq("Test Project")
122
- expect(hash[:stage]).to eq("DIS")
123
- expect(hash[:comments]).to be_an(Array)
124
- expect(hash[:comments].first).to be_a(Hash)
118
+ expect(hash["version"]).to eq("2012-03")
119
+ expect(hash["date"]).to eq("2024-06-04")
120
+ expect(hash["document"]).to eq("Test Document")
121
+ expect(hash["project"]).to eq("Test Project")
122
+ expect(hash["stage"]).to eq("DIS")
123
+ expect(hash["comments"]).to be_an(Array)
124
+ expect(hash["comments"].first).to be_a(Hash)
125
125
  end
126
126
 
127
- it "includes nil stage when not set" do
127
+ it "omits unset attributes from the hash" do
128
128
  sheet = described_class.new({})
129
129
  hash = sheet.to_h
130
130
 
131
- expect(hash).to have_key(:stage)
132
- expect(hash[:stage]).to be_nil
131
+ expect(hash).not_to have_key("stage")
132
+ expect(sheet.version).to eq("2012-03")
133
133
  end
134
134
  end
135
135
 
@@ -169,6 +169,31 @@ RSpec.describe Commenter::CommentSheet do
169
169
  expect(sheet.comments.length).to eq(1)
170
170
  end
171
171
  end
172
+ end
173
+
174
+ RSpec.describe Commenter::CommentSheet do
175
+ describe "#schema_name" do
176
+ it "selects the schema for each sheet version" do
177
+ expect(described_class.new(version: "osd").schema_name).to eq("iso_comment_osd.yaml")
178
+ expect(described_class.new(version: "2012-03").schema_name).to eq("iso_comment_2012-03.yaml")
179
+ expect(described_class.new({}).schema_name).to eq("iso_comment_2012-03.yaml")
180
+ end
181
+ end
182
+
183
+ describe "#to_yaml_document" do
184
+ it "prepends a header referencing the schema matching the version" do
185
+ document = described_class.new(version: "osd", comments: []).to_yaml_document
186
+
187
+ expect(document.lines.first).to eq("# yaml-language-server: $schema=schema/iso_comment_osd.yaml\n")
188
+ expect(YAML.safe_load(document)).to include("version" => "osd")
189
+ end
190
+
191
+ it "accepts a schema directory" do
192
+ document = described_class.new(comments: []).to_yaml_document("schemas")
193
+
194
+ expect(document.lines.first).to eq("# yaml-language-server: $schema=schemas/iso_comment_2012-03.yaml\n")
195
+ end
196
+ end
172
197
 
173
198
  describe "stage validation" do
174
199
  it "accepts valid stage values" do