canon 0.1.3 → 0.1.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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -1
  3. data/.rubocop_todo.yml +276 -7
  4. data/README.adoc +203 -138
  5. data/_config.yml +116 -0
  6. data/docs/ADVANCED_TOPICS.adoc +20 -0
  7. data/docs/BASIC_USAGE.adoc +16 -0
  8. data/docs/CHARACTER_VISUALIZATION.adoc +567 -0
  9. data/docs/CLI.adoc +493 -0
  10. data/docs/CUSTOMIZING_BEHAVIOR.adoc +19 -0
  11. data/docs/DIFF_ARCHITECTURE.adoc +435 -0
  12. data/docs/DIFF_FORMATTING.adoc +540 -0
  13. data/docs/FORMATS.adoc +447 -0
  14. data/docs/INDEX.adoc +222 -0
  15. data/docs/INPUT_VALIDATION.adoc +477 -0
  16. data/docs/MATCH_ARCHITECTURE.adoc +463 -0
  17. data/docs/MATCH_OPTIONS.adoc +719 -0
  18. data/docs/MODES.adoc +432 -0
  19. data/docs/NORMATIVE_INFORMATIVE_DIFFS.adoc +219 -0
  20. data/docs/OPTIONS.adoc +1387 -0
  21. data/docs/PREPROCESSING.adoc +491 -0
  22. data/docs/RSPEC.adoc +605 -0
  23. data/docs/RUBY_API.adoc +478 -0
  24. data/docs/SEMANTIC_DIFF_REPORT.adoc +528 -0
  25. data/docs/UNDERSTANDING_CANON.adoc +17 -0
  26. data/docs/VERBOSE.adoc +482 -0
  27. data/exe/canon +7 -0
  28. data/lib/canon/cli.rb +179 -0
  29. data/lib/canon/commands/diff_command.rb +195 -0
  30. data/lib/canon/commands/format_command.rb +113 -0
  31. data/lib/canon/comparison/base_comparator.rb +39 -0
  32. data/lib/canon/comparison/comparison_result.rb +79 -0
  33. data/lib/canon/comparison/html_comparator.rb +410 -0
  34. data/lib/canon/comparison/json_comparator.rb +212 -0
  35. data/lib/canon/comparison/match_options.rb +616 -0
  36. data/lib/canon/comparison/xml_comparator.rb +566 -0
  37. data/lib/canon/comparison/yaml_comparator.rb +93 -0
  38. data/lib/canon/comparison.rb +239 -0
  39. data/lib/canon/config.rb +172 -0
  40. data/lib/canon/diff/diff_block.rb +71 -0
  41. data/lib/canon/diff/diff_block_builder.rb +105 -0
  42. data/lib/canon/diff/diff_classifier.rb +46 -0
  43. data/lib/canon/diff/diff_context.rb +85 -0
  44. data/lib/canon/diff/diff_context_builder.rb +107 -0
  45. data/lib/canon/diff/diff_line.rb +77 -0
  46. data/lib/canon/diff/diff_node.rb +56 -0
  47. data/lib/canon/diff/diff_node_mapper.rb +148 -0
  48. data/lib/canon/diff/diff_report.rb +133 -0
  49. data/lib/canon/diff/diff_report_builder.rb +62 -0
  50. data/lib/canon/diff_formatter/by_line/base_formatter.rb +407 -0
  51. data/lib/canon/diff_formatter/by_line/html_formatter.rb +672 -0
  52. data/lib/canon/diff_formatter/by_line/json_formatter.rb +284 -0
  53. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +190 -0
  54. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +860 -0
  55. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +292 -0
  56. data/lib/canon/diff_formatter/by_object/base_formatter.rb +199 -0
  57. data/lib/canon/diff_formatter/by_object/json_formatter.rb +305 -0
  58. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +248 -0
  59. data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +17 -0
  60. data/lib/canon/diff_formatter/character_map.yml +197 -0
  61. data/lib/canon/diff_formatter/debug_output.rb +431 -0
  62. data/lib/canon/diff_formatter/diff_detail_formatter.rb +551 -0
  63. data/lib/canon/diff_formatter/legend.rb +141 -0
  64. data/lib/canon/diff_formatter.rb +520 -0
  65. data/lib/canon/errors.rb +56 -0
  66. data/lib/canon/formatters/html4_formatter.rb +17 -0
  67. data/lib/canon/formatters/html5_formatter.rb +17 -0
  68. data/lib/canon/formatters/html_formatter.rb +37 -0
  69. data/lib/canon/formatters/html_formatter_base.rb +163 -0
  70. data/lib/canon/formatters/json_formatter.rb +3 -0
  71. data/lib/canon/formatters/xml_formatter.rb +20 -55
  72. data/lib/canon/formatters/yaml_formatter.rb +4 -1
  73. data/lib/canon/pretty_printer/html.rb +57 -0
  74. data/lib/canon/pretty_printer/json.rb +25 -0
  75. data/lib/canon/pretty_printer/xml.rb +29 -0
  76. data/lib/canon/rspec_matchers.rb +222 -80
  77. data/lib/canon/validators/base_validator.rb +49 -0
  78. data/lib/canon/validators/html_validator.rb +138 -0
  79. data/lib/canon/validators/json_validator.rb +89 -0
  80. data/lib/canon/validators/xml_validator.rb +53 -0
  81. data/lib/canon/validators/yaml_validator.rb +73 -0
  82. data/lib/canon/version.rb +1 -1
  83. data/lib/canon/xml/attribute_handler.rb +80 -0
  84. data/lib/canon/xml/c14n.rb +36 -0
  85. data/lib/canon/xml/character_encoder.rb +38 -0
  86. data/lib/canon/xml/data_model.rb +225 -0
  87. data/lib/canon/xml/element_matcher.rb +196 -0
  88. data/lib/canon/xml/line_range_mapper.rb +158 -0
  89. data/lib/canon/xml/namespace_handler.rb +86 -0
  90. data/lib/canon/xml/node.rb +32 -0
  91. data/lib/canon/xml/nodes/attribute_node.rb +54 -0
  92. data/lib/canon/xml/nodes/comment_node.rb +23 -0
  93. data/lib/canon/xml/nodes/element_node.rb +56 -0
  94. data/lib/canon/xml/nodes/namespace_node.rb +38 -0
  95. data/lib/canon/xml/nodes/processing_instruction_node.rb +24 -0
  96. data/lib/canon/xml/nodes/root_node.rb +16 -0
  97. data/lib/canon/xml/nodes/text_node.rb +23 -0
  98. data/lib/canon/xml/processor.rb +151 -0
  99. data/lib/canon/xml/whitespace_normalizer.rb +72 -0
  100. data/lib/canon/xml/xml_base_handler.rb +188 -0
  101. data/lib/canon.rb +14 -3
  102. metadata +116 -21
@@ -0,0 +1,435 @@
1
+ ---
2
+ layout: default
3
+ title: Diff Architecture
4
+ nav_order: 43
5
+ parent: Advanced Topics
6
+ ---
7
+ = Canon Diff Architecture
8
+ :toc:
9
+ :toclevels: 3
10
+
11
+ == Overview
12
+
13
+ Canon's diff system follows a strict separation of concerns with each layer having a single, well-defined responsibility. This document describes the complete architecture from comparison through formatting.
14
+
15
+ == Architecture Layers
16
+
17
+ [source]
18
+ ----
19
+ ┌─────────────────────────────────────────────────────────────────────────┐
20
+ │ LAYER 1: COMPARISON │
21
+ │ Creates Semantic Differences │
22
+ ├─────────────────────────────────────────────────────────────────────────┤
23
+ │ │
24
+ │ Input: Two documents (doc1, doc2) │
25
+ │ Process: XmlComparator.equivalent?(doc1, doc2, options) │
26
+ │ Output: DiffNode[] │
27
+ │ │
28
+ │ ┌────────────────────────────────────────────────────────────┐ │
29
+ │ │ DiffNode │ │
30
+ │ │ ──────── │ │
31
+ │ │ • node1: Element from doc1 │ │
32
+ │ │ • node2: Element from doc2 │ │
33
+ │ │ • dimension: :text_content, :attribute_whitespace, etc │ │
34
+ │ │ • reason: "7 vs 9" or descriptive text │ │
35
+ │ │ • normative: nil (to be classified) │ │
36
+ │ └────────────────────────────────────────────────────────────┘ │
37
+ │ │
38
+ │ Then: DiffClassifier.classify_all(diff_nodes, match_options) │
39
+ │ Sets normative=true/false based on match dimension behavior │
40
+ │ │
41
+ └─────────────────────────────────────────────────────────────────────────┘
42
+
43
+ ┌─────────────────────────────────────────────────────────────────────────┐
44
+ │ LAYER 2: MAPPING (DiffNodes → Lines) │
45
+ │ Maps Semantic Diffs to Text Lines │
46
+ ├─────────────────────────────────────────────────────────────────────────┤
47
+ │ │
48
+ │ Input: DiffNode[], text1, text2 │
49
+ │ Process: DiffNodeMapper.map_to_lines(diff_nodes, text1, text2) │
50
+ │ Output: DiffLine[] │
51
+ │ │
52
+ │ ┌────────────────────────────────────────────────────────────┐ │
53
+ │ │ DiffLine │ │
54
+ │ │ ──────── │ │
55
+ │ │ • line_number: Integer │ │
56
+ │ │ • content: String (the line text) │ │
57
+ │ │ • type: :unchanged, :added, :removed, :changed │ │
58
+ │ │ • diff_node: DiffNode reference │ │
59
+ │ │ • normative?: boolean (inherited from diff_node) │ │
60
+ │ └────────────────────────────────────────────────────────────┘ │
61
+ │ │
62
+ │ How it works: │
63
+ │ 1. Run text diff (Diff::LCS) on original text │
64
+ │ 2. For each changed line, find corresponding DiffNode │
65
+ │ 3. Create DiffLine linking line ↔ DiffNode │
66
+ │ 4. Inherit normative/informative from DiffNode │
67
+ │ │
68
+ └─────────────────────────────────────────────────────────────────────────┘
69
+
70
+ ┌─────────────────────────────────────────────────────────────────────────┐
71
+ │ LAYER 3: BLOCKING (DiffLines → DiffBlocks) │
72
+ │ Groups Contiguous Changed Lines │
73
+ ├─────────────────────────────────────────────────────────────────────────┤
74
+ │ │
75
+ │ Input: DiffLine[] │
76
+ │ Process: DiffBlockBuilder.build_blocks(diff_lines, show_diffs) │
77
+ │ Output: DiffBlock[] │
78
+ │ │
79
+ │ ┌────────────────────────────────────────────────────────────┐ │
80
+ │ │ DiffBlock │ │
81
+ │ │ ───────── │ │
82
+ │ │ • start_idx: Integer │ │
83
+ │ │ • end_idx: Integer │ │
84
+ │ │ • types: ['-', '+', '!'] │ │
85
+ │ │ • diff_lines: DiffLine[] │ │
86
+ │ │ • diff_node: DiffNode (if all lines from same node) │ │
87
+ │ │ • normative?: true if ANY diff_line is normative │ │
88
+ │ └────────────────────────────────────────────────────────────┘ │
89
+ │ │
90
+ │ Filtering: │
91
+ │ • show_diffs: :normative → keep only blocks with normative?=true │
92
+ │ • show_diffs: :informative → keep only blocks with normative?=false │
93
+ │ • show_diffs: :all → keep all blocks │
94
+ │ │
95
+ └─────────────────────────────────────────────────────────────────────────┘
96
+
97
+ ┌─────────────────────────────────────────────────────────────────────────┐
98
+ │ LAYER 4: CONTEXTING (DiffBlocks → DiffContexts) │
99
+ │ Adds Surrounding Context Lines │
100
+ ├─────────────────────────────────────────────────────────────────────────┤
101
+ │ │
102
+ │ Input: DiffBlock[], context_lines, grouping_lines │
103
+ │ Process: DiffContextBuilder.build_contexts(blocks, options) │
104
+ │ Output: DiffContext[] │
105
+ │ │
106
+ │ ┌────────────────────────────────────────────────────────────┐ │
107
+ │ │ DiffContext │ │
108
+ │ │ ─────────── │ │
109
+ │ │ • start_idx: Integer (includes context before) │ │
110
+ │ │ • end_idx: Integer (includes context after) │ │
111
+ │ │ • blocks: DiffBlock[] │ │
112
+ │ └────────────────────────────────────────────────────────────┘ │
113
+ │ │
114
+ │ Process: │
115
+ │ 1. Group blocks by proximity (grouping_lines) │
116
+ │ 2. Expand each group with context_lines before/after │
117
+ │ 3. Create DiffContext for each group │
118
+ │ │
119
+ └─────────────────────────────────────────────────────────────────────────┘
120
+
121
+ ┌─────────────────────────────────────────────────────────────────────────┐
122
+ │ LAYER 5: REPORTING (Wrap in DiffReport) │
123
+ │ Top-Level Container │
124
+ ├─────────────────────────────────────────────────────────────────────────┤
125
+ │ │
126
+ │ Input: DiffContext[], metadata │
127
+ │ Process: DiffReportBuilder.build(diff_nodes, text1, text2, opts) │
128
+ │ Output: DiffReport │
129
+ │ │
130
+ │ ┌────────────────────────────────────────────────────────────┐ │
131
+ │ │ DiffReport │ │
132
+ │ │ ────────── │ │
133
+ │ │ • element_name: String │ │
134
+ │ │ • file1_name: String │ │
135
+ │ │ • file2_name: String │ │
136
+ │ │ • contexts: DiffContext[] │ │
137
+ │ │ • has_differences?: boolean │ │
138
+ │ └────────────────────────────────────────────────────────────┘ │
139
+ │ │
140
+ │ This is the SINGLE source of truth for what to display │
141
+ │ │
142
+ └─────────────────────────────────────────────────────────────────────────┘
143
+
144
+ ┌─────────────────────────────────────────────────────────────────────────┐
145
+ │ LAYER 6: FORMATTING (Render to String) │
146
+ │ Display Only │
147
+ ├─────────────────────────────────────────────────────────────────────────┤
148
+ │ │
149
+ │ Input: DiffReport │
150
+ │ Process: Formatter.format(diff_report) │
151
+ │ Output: Formatted string │
152
+ │ │
153
+ │ Formatters: │
154
+ │ • ByLine::HtmlFormatter - HTML with DOM-aware display │
155
+ │ • ByLine::XmlFormatter - XML with token highlighting │
156
+ │ • ByLine::JsonFormatter - JSON pretty-printed │
157
+ │ • ByObject::XmlFormatter - Tree visualization │
158
+ │ │
159
+ │ Formatter responsibilities: │
160
+ │ • Render DiffContext objects │
161
+ │ • Apply visualization (colors, symbols) │
162
+ │ • Format line numbers │
163
+ │ • NO comparison, NO filtering, NO business logic │
164
+ │ │
165
+ └─────────────────────────────────────────────────────────────────────────┘
166
+ ----
167
+
168
+ == Data Flow Example
169
+
170
+ === Example: Attribute Order Difference (Normalized Away)
171
+
172
+ [source]
173
+ ----
174
+ Input:
175
+ doc1: <div class="TOC" id="_">Content</div>
176
+ doc2: <div id="_" class="TOC">Content</div>
177
+
178
+ Layer 1 - Comparison:
179
+ XmlComparator compares → NO DiffNode created
180
+ (attributes normalized, so equivalent)
181
+
182
+ Layer 2 - Mapping:
183
+ DiffNodeMapper receives empty DiffNode[]
184
+ No DiffLines created
185
+
186
+ Layer 3 - Blocking:
187
+ DiffBlockBuilder receives empty DiffLine[]
188
+ No DiffBlocks created
189
+
190
+ Layer 4 - Contexting:
191
+ DiffContextBuilder receives empty DiffBlock[]
192
+ No DiffContexts created
193
+
194
+ Layer 5 - Reporting:
195
+ DiffReport(contexts: [])
196
+ has_differences? → false
197
+
198
+ Layer 6 - Formatting:
199
+ Formatter sees empty contexts
200
+ Returns empty string (no diff to show)
201
+ ----
202
+
203
+ === Example: Real Text Difference
204
+
205
+ [source]
206
+ ----
207
+ Input:
208
+ doc1: <p>Test 1</p>
209
+ doc2: <p>Test 2</p>
210
+
211
+ Layer 1 - Comparison:
212
+ XmlComparator finds text differs
213
+ Creates: DiffNode(dimension: :text_content, normative: true)
214
+
215
+ Layer 2 - Mapping:
216
+ DiffNodeMapper maps to text lines
217
+ Creates: DiffLine(type: :changed, diff_node: DiffNode, normative: true)
218
+
219
+ Layer 3 - Blocking:
220
+ DiffBlockBuilder groups lines
221
+ Creates: DiffBlock(diff_lines: [DiffLine], normative: true)
222
+ With show_diffs: :normative → keeps this block
223
+
224
+ Layer 4 - Contexting:
225
+ DiffContextBuilder adds context
226
+ Creates: DiffContext(blocks: [DiffBlock], start_idx: 0, end_idx: 2)
227
+
228
+ Layer 5 - Reporting:
229
+ DiffReport(contexts: [DiffContext])
230
+ has_differences? → true
231
+
232
+ Layer 6 - Formatting:
233
+ Formatter renders:
234
+ 1| - | <p>Test 1</p>
235
+ | 1+ | <p>Test 2</p>
236
+ ----
237
+
238
+ === Example: Mixed Normative and Informative Diffs
239
+
240
+ [source]
241
+ ----
242
+ Input:
243
+ doc1: <div class="foo" id="x"><p>Old</p></div>
244
+ doc2: <div id="x" class="foo"><p>New</p></div>
245
+
246
+ Layer 1 - Comparison:
247
+ • Attribute order normalized → NO DiffNode for <div>
248
+ • Text differs → DiffNode(dimension: :text_content) for <p>
249
+ Result: 1 DiffNode (normative)
250
+
251
+ Layer 2 - Mapping:
252
+ Maps <p> text difference to line 1
253
+ Creates: 1 DiffLine for <p> change
254
+ <div> line has NO DiffLine (no DiffNode for it)
255
+
256
+ Layer 3 - Blocking:
257
+ Creates: 1 DiffBlock for <p> line
258
+
259
+ Layer 4 - Contexting:
260
+ Creates: 1 DiffContext (might include <div> line as context)
261
+
262
+ Layer 6 - Formatting:
263
+ Shows only <p> change:
264
+ 1| - | <p>Old</p>
265
+ | 1+ | <p>New</p>
266
+
267
+ Does NOT show <div> with attribute order diff ✓
268
+ ----
269
+
270
+ == Class Responsibilities
271
+
272
+ === Comparison Layer
273
+
274
+ **XmlComparator**::
275
+ **Single Responsibility**: Compare DOM nodes semantically
276
+ **Creates**: DiffNode objects for semantic differences
277
+ **Does NOT**: Handle text lines, formatting, filtering
278
+
279
+ **DiffClassifier**::
280
+ **Single Responsibility**: Classify DiffNodes as normative/informative
281
+ **Input**: DiffNode[], MatchOptions
282
+ **Output**: Same DiffNodes with `normative` set
283
+ **Logic**: normative = (dimension behavior != :ignore)
284
+
285
+ === Mapping Layer
286
+
287
+ **DiffNodeMapper** (ENHANCE)::
288
+ **Single Responsibility**: Map semantic diffs to text line positions
289
+ **Input**: DiffNode[], text1, text2
290
+ **Output**: DiffLine[]
291
+ **Process**:
292
+ 1. Run Diff::LCS.sdiff on text
293
+ 2. For each changed line, find owning DiffNode
294
+ 3. Create DiffLine linking line ↔ DiffNode
295
+ 4. Inherit normative/informative from DiffNode
296
+
297
+ === Processing Layer
298
+
299
+ **DiffBlockBuilder** (NEW)::
300
+ **Single Responsibility**: Group contiguous DiffLines into blocks
301
+ **Input**: DiffLine[], show_diffs option
302
+ **Output**: DiffBlock[]
303
+ **Process**:
304
+ 1. Identify runs of changed lines
305
+ 2. Create DiffBlock for each run
306
+ 3. Set block.normative based on diff_lines
307
+ 4. Filter blocks by show_diffs
308
+
309
+ **DiffContextBuilder** (NEW)::
310
+ **Single Responsibility**: Add context lines around blocks
311
+ **Input**: DiffBlock[], context_lines, grouping_lines
312
+ **Output**: DiffContext[]
313
+ **Process**:
314
+ 1. Group nearby blocks (within grouping_lines)
315
+ 2. Expand each group with context_lines
316
+ 3. Create DiffContext for each group
317
+
318
+ **DiffReportBuilder** (NEW)::
319
+ **Single Responsibility**: Orchestrate the pipeline
320
+ **Input**: DiffNode[], text1, text2, options
321
+ **Output**: DiffReport
322
+ **Process**:
323
+ 1. DiffNodeMapper → DiffLines
324
+ 2. DiffBlockBuilder → DiffBlocks
325
+ 3. DiffContextBuilder → DiffContexts
326
+ 4. Wrap in DiffReport
327
+
328
+ === Formatting Layer
329
+
330
+ **Formatters** (REFACTOR)::
331
+ **Single Responsibility**: Render DiffReport to string
332
+ **Input**: DiffReport (NOT raw text!)
333
+ **Output**: Formatted string
334
+ **Does**: Apply colors, line numbers, visualization
335
+ **Does NOT**: Compare, map, filter, or create blocks
336
+
337
+ == Key Principles
338
+
339
+ === Single Responsibility
340
+
341
+ Each class does ONE thing:
342
+ - **Comparator**: Compares → DiffNodes
343
+ - **Mapper**: Maps nodes → lines
344
+ - **BlockBuilder**: Groups lines → blocks
345
+ - **ContextBuilder**: Adds context → contexts
346
+ - **ReportBuilder**: Orchestrates pipeline
347
+ - **Formatter**: Renders report → string
348
+
349
+ === Separation of Concerns
350
+
351
+ **Business Logic** (Comparison, Mapping, Blocking):
352
+ - Lives in `lib/canon/diff/` and `lib/canon/comparison/`
353
+ - No knowledge of rendering or colors
354
+ - Pure data transformations
355
+
356
+ **Presentation** (Formatting):
357
+ - Lives in `lib/canon/diff_formatter/`
358
+ - No business logic
359
+ - Just renders what it's given
360
+
361
+ === Information Expert
362
+
363
+ Each object knows about its own data:
364
+ - `DiffNode.normative?` - knows if it's semantically different
365
+ - `DiffLine.normative?` - knows via its DiffNode
366
+ - `DiffBlock.normative?` - knows via its DiffLines
367
+ - `DiffContext` - knows about its blocks
368
+
369
+ === Tell, Don't Ask
370
+
371
+ Don't ask objects for data to make decisions elsewhere:
372
+ ```ruby
373
+ # BAD (Ask)
374
+ if diff_node.dimension == :attribute_whitespace &&
375
+ match_options[:attribute_whitespace] == :ignore
376
+ # make decision here
377
+ end
378
+
379
+ # GOOD (Tell)
380
+ if diff_node.normative?
381
+ # decision already made by DiffNode/DiffClassifier
382
+ end
383
+ ```
384
+
385
+ == Implementation Plan
386
+
387
+ === Phase 1: Enhance DiffNodeMapper
388
+
389
+ Make it actually map DiffNodes to line positions.
390
+
391
+ === Phase 2: Create Builders
392
+
393
+ **DiffBlockBuilder**:
394
+ - Groups DiffLines into DiffBlocks
395
+ - Filters by show_diffs
396
+ - Sets normative? on blocks
397
+
398
+ **DiffContextBuilder**:
399
+ - Groups DiffBlocks by proximity
400
+ - Expands with context lines
401
+ - Creates DiffContexts
402
+
403
+ **DiffReportBuilder**:
404
+ - Orchestrates the full pipeline
405
+ - Single entry point
406
+
407
+ === Phase 3: Refactor Formatters
408
+
409
+ Remove all comparison/mapping/blocking logic.
410
+ Accept DiffReport and just render it.
411
+
412
+ === Phase 4: Update Callers
413
+
414
+ Change from:
415
+ ```ruby
416
+ formatter.format(doc1, doc2) # OLD - formatters did everything
417
+ ```
418
+
419
+ To:
420
+ ```ruby
421
+ report = DiffReportBuilder.build(diff_nodes, text1, text2, options)
422
+ formatter.format(report) # NEW - just renders
423
+ ```
424
+
425
+ == Benefits
426
+
427
+ **Fixes Issue 1**: When DiffNodes are empty (all normalized), DiffReport has no contexts → no output
428
+
429
+ **Fixes Issue 2**: Empty diffs handled at DiffReport level, not formatter hacks
430
+
431
+ **Testable**: Each class tested independently
432
+
433
+ **Maintainable**: Clear responsibilities, easy to understand
434
+
435
+ **Extensible**: Easy to add new filtering, grouping, or rendering strategies