ucode 0.1.1 → 0.2.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.extract-code-chart/01-pdf-fetch-validation.md +80 -0
  3. data/TODO.extract-code-chart/02-block-name-resolver.md +68 -0
  4. data/TODO.extract-code-chart/03-codechart-namespace.md +82 -0
  5. data/TODO.extract-code-chart/04-codechart-extractor.md +154 -0
  6. data/TODO.extract-code-chart/05-provenance-and-sidecar.md +147 -0
  7. data/TODO.extract-code-chart/06-codechart-writer.md +134 -0
  8. data/TODO.extract-code-chart/07-codechart-cli.md +135 -0
  9. data/TODO.extract-code-chart/08-specs.md +87 -0
  10. data/lib/ucode/audit/reference_factory.rb +66 -0
  11. data/lib/ucode/audit.rb +1 -0
  12. data/lib/ucode/cli.rb +134 -16
  13. data/lib/ucode/code_chart/extractor.rb +122 -0
  14. data/lib/ucode/code_chart/provenance.rb +81 -0
  15. data/lib/ucode/code_chart/sidecar.rb +52 -0
  16. data/lib/ucode/code_chart/writer.rb +128 -0
  17. data/lib/ucode/code_chart.rb +39 -0
  18. data/lib/ucode/commands/audit.rb +0 -1
  19. data/lib/ucode/commands/build.rb +4 -0
  20. data/lib/ucode/commands/canonical_build.rb +2 -3
  21. data/lib/ucode/commands/fetch.rb +12 -14
  22. data/lib/ucode/commands/glyphs.rb +25 -67
  23. data/lib/ucode/commands/lookup.rb +11 -11
  24. data/lib/ucode/commands/parse.rb +7 -5
  25. data/lib/ucode/commands/release.rb +0 -1
  26. data/lib/ucode/commands/universal_set.rb +10 -14
  27. data/lib/ucode/coordinator/indices.rb +38 -2
  28. data/lib/ucode/error.rb +11 -0
  29. data/lib/ucode/fetch/code_charts.rb +1 -1
  30. data/lib/ucode/fetch/http.rb +84 -14
  31. data/lib/ucode/glyphs/pipeline.rb +106 -0
  32. data/lib/ucode/glyphs.rb +1 -0
  33. data/lib/ucode/parsers/blocks.rb +34 -0
  34. data/lib/ucode/repo/aggregate_writer.rb +60 -298
  35. data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
  36. data/lib/ucode/repo/writers/enums_writer.rb +38 -0
  37. data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
  38. data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
  39. data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
  40. data/lib/ucode/repo/writers/planes_writer.rb +82 -0
  41. data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
  42. data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
  43. data/lib/ucode/repo/writers.rb +20 -0
  44. data/lib/ucode/repo.rb +1 -0
  45. data/lib/ucode/version.rb +1 -1
  46. data/lib/ucode.rb +3 -0
  47. data/ucode.gemspec +56 -0
  48. metadata +31 -5
  49. data/Gemfile.lock +0 -406
  50. data/lib/ucode/commands/audit/reference_builder.rb +0 -64
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
- require "json"
5
- require "time"
6
4
 
7
- require "ucode/models"
8
- require "ucode/repo/atomic_writes"
9
- require "ucode/repo/paths"
5
+ require "ucode/repo/writers/planes_writer"
6
+ require "ucode/repo/writers/blocks_writer"
7
+ require "ucode/repo/writers/scripts_writer"
8
+ require "ucode/repo/writers/indexes_writer"
9
+ require "ucode/repo/writers/relationships_writer"
10
+ require "ucode/repo/writers/enums_writer"
11
+ require "ucode/repo/writers/named_sequences_writer"
12
+ require "ucode/repo/writers/manifest_writer"
10
13
 
11
14
  module Ucode
12
15
  module Repo
@@ -14,67 +17,30 @@ module Ucode
14
17
  #
15
18
  # output/planes/<n>.json
16
19
  # output/blocks/<ID>.json
17
- # output/blocks/index.json (block index)
20
+ # output/blocks/index.json
18
21
  # output/scripts/<code>.json
19
- # output/index/names.json (cp_id → name)
20
- # output/index/labels.json (cp_id → {name, gc, sc, cc, bc, mir})
21
- # output/index/codepoint_to_block.json (cp_id → block_id)
22
- # output/relationships/*.json (per-property tables)
23
- # output/enums.json (property aliases + value aliases)
22
+ # output/index/names.json
23
+ # output/index/labels.json
24
+ # output/index/codepoint_to_block.json
25
+ # output/relationships/*.json
26
+ # output/enums.json
24
27
  # output/named_sequences/<slug>.json
25
28
  # output/manifest.json
26
29
  #
27
- # **Single pass**: callers feed one CodePoint at a time via `#add`;
28
- # `#flush` writes all derived files using the Coordinator's indices
29
- # for the static tables (relationships, enums, named sequences).
30
+ # **Single pass**: callers feed one CodePoint at a time via `#add`,
31
+ # which folds into the streaming accumulators. `#flush` then
32
+ # composes eight per-concern writer classes (one per output kind)
33
+ # and runs them in order. Adding a new aggregate = adding one
34
+ # writer class + one line here. See Candidate 5 of the 2026-06-29
35
+ # architecture review.
30
36
  #
31
37
  # **MECE**:
32
38
  # - paths: `Repo::Paths`
33
39
  # - atomic writes: `Repo::AtomicWrites`
34
- # - stream aggregation: this class
40
+ # - stream aggregation: this class (the `#add` half)
41
+ # - per-concern writers: `Repo::Writers::*`
35
42
  # - serialization: lutaml-model `to_yaml_hash` / `to_json`
36
43
  class AggregateWriter
37
- include AtomicWrites
38
-
39
- # Static metadata for the 17 Unicode planes. Planes 4–13 are
40
- # unassigned in Unicode 17; their entries use placeholder names.
41
- PLANE_TABLE = {
42
- 0 => ["Basic Multilingual Plane", "BMP"],
43
- 1 => ["Supplementary Multilingual Plane", "SMP"],
44
- 2 => ["Supplementary Ideographic Plane", "SIP"],
45
- 3 => ["Tertiary Ideographic Plane", "TIP"],
46
- 4 => ["Unassigned Plane 4", "—"],
47
- 5 => ["Unassigned Plane 5", "—"],
48
- 6 => ["Unassigned Plane 6", "—"],
49
- 7 => ["Unassigned Plane 7", "—"],
50
- 8 => ["Unassigned Plane 8", "—"],
51
- 9 => ["Unassigned Plane 9", "—"],
52
- 10 => ["Unassigned Plane 10", "—"],
53
- 11 => ["Unassigned Plane 11", "—"],
54
- 12 => ["Unassigned Plane 12", "—"],
55
- 13 => ["Unassigned Plane 13", "—"],
56
- 14 => ["Supplementary Special-purpose Plane", "SSP"],
57
- 15 => ["Supplementary Private Use Area-A", "SPUA-A"],
58
- 16 => ["Supplementary Private Use Area-B", "SPUA-B"],
59
- }.freeze
60
- private_constant :PLANE_TABLE
61
-
62
- # Coordinator::Indices fields paired with the file slug used
63
- # under `output/relationships/`. Each field is a Hash<Integer,
64
- # Record> or Hash<Integer, Array<Record>>.
65
- RELATIONSHIP_SOURCES = {
66
- special_casing: "special_casing",
67
- case_folding: "case_folding",
68
- bidi_mirroring: "bidi_mirroring",
69
- bidi_brackets: "bidi_brackets",
70
- cjk_radicals: "cjk_radicals",
71
- standardized_variants: "standardized_variants",
72
- name_aliases: "name_aliases",
73
- }.freeze
74
- private_constant :RELATIONSHIP_SOURCES
75
-
76
- attr_reader :codepoint_count
77
-
78
44
  # @param output_root [String, Pathname]
79
45
  def initialize(output_root)
80
46
  @output_root = Pathname.new(output_root)
@@ -87,6 +53,8 @@ module Ucode
87
53
  @codepoint_count = 0
88
54
  end
89
55
 
56
+ attr_reader :codepoint_count
57
+
90
58
  # Fold one CodePoint into the stream accumulators. No-ops if the
91
59
  # cp has no block_id (it has no home in the output tree).
92
60
  # @param cp [Ucode::Models::CodePoint]
@@ -107,10 +75,8 @@ module Ucode
107
75
  @codepoint_count += 1
108
76
  end
109
77
 
110
- # Write every aggregate file. Optional params supply data that is
111
- # not in `Coordinator::Indices` (the Coordinator only resolves the
112
- # `sc` subset of PropertyValueAliases; the full alias tables and
113
- # the named sequences are passed through from the CLI/parsers).
78
+ # Compose the eight per-concern writers, run them in order, and
79
+ # return the total number of files written.
114
80
  #
115
81
  # @param ucd_version [String]
116
82
  # @param indices [Ucode::Coordinator::Indices]
@@ -118,19 +84,40 @@ module Ucode
118
84
  # @param property_value_aliases [Array<Ucode::Models::PropertyValueAlias>]
119
85
  # @param named_sequences [Array<Ucode::Models::NamedSequence>]
120
86
  # @param glyph_count [Integer]
121
- # @return [Integer] number of files written
87
+ # @return [Integer]
122
88
  def flush(ucd_version:, indices:, property_aliases: [],
123
89
  property_value_aliases: [], named_sequences: [], glyph_count: 0)
124
- writes = 0
125
- writes += write_planes(indices.blocks)
126
- writes += write_blocks(indices.blocks)
127
- writes += write_scripts(indices.scripts)
128
- writes += write_indexes
129
- writes += write_relationships(indices)
130
- writes += write_enums(property_aliases, property_value_aliases)
131
- writes += write_named_sequences(named_sequences)
132
- writes += write_manifest(ucd_version: ucd_version, glyph_count: glyph_count)
133
- writes
90
+ writers(ucd_version, indices, property_aliases, property_value_aliases,
91
+ named_sequences, glyph_count).sum(&:write)
92
+ end
93
+
94
+ # @api private — exposed for testing.
95
+ def writers(ucd_version, indices, property_aliases,
96
+ property_value_aliases, named_sequences, glyph_count)
97
+ [
98
+ Writers::PlanesWriter.new(output_root: @output_root, blocks: indices.blocks),
99
+ Writers::BlocksWriter.new(output_root: @output_root,
100
+ blocks: indices.blocks,
101
+ block_codepoint_ids: @block_codepoint_ids,
102
+ block_ages: @block_ages),
103
+ Writers::ScriptsWriter.new(output_root: @output_root,
104
+ scripts: indices.scripts,
105
+ script_codepoint_ids: @script_codepoint_ids),
106
+ Writers::IndexesWriter.new(output_root: @output_root,
107
+ names: @names_index,
108
+ labels: @labels_index,
109
+ cp_to_block: @cp_to_block),
110
+ Writers::RelationshipsWriter.new(output_root: @output_root, indices: indices),
111
+ Writers::EnumsWriter.new(output_root: @output_root,
112
+ property_aliases: property_aliases,
113
+ property_value_aliases: property_value_aliases),
114
+ Writers::NamedSequencesWriter.new(output_root: @output_root,
115
+ named_sequences: named_sequences),
116
+ Writers::ManifestWriter.new(output_root: @output_root,
117
+ ucd_version: ucd_version,
118
+ codepoint_count: @codepoint_count,
119
+ glyph_count: glyph_count),
120
+ ]
134
121
  end
135
122
 
136
123
  private
@@ -151,9 +138,7 @@ module Ucode
151
138
 
152
139
  # Per-block `age` is the earliest DerivedAge of any codepoint in
153
140
  # the block, compared as a Gem::Version. Stored as the original
154
- # string (e.g. "1.1", "17.0.0"). nil when no codepoint in the
155
- # block has an age (rare — only happens for entirely-reserved
156
- # blocks, which the parser excludes anyway).
141
+ # string (e.g. "1.1", "17.0.0").
157
142
  def track_block_age(cp)
158
143
  return if cp.age.nil? || cp.age.empty?
159
144
 
@@ -168,229 +153,6 @@ module Ucode
168
153
  def min_age(a, b)
169
154
  Gem::Version.new(a) < Gem::Version.new(b) ? a : b
170
155
  end
171
-
172
- # ---- Plane files -------------------------------------------------
173
-
174
- def write_planes(blocks)
175
- plane_block_ids = group_block_ids_by_plane(blocks)
176
- count = 0
177
- (0..16).each do |n|
178
- path = Paths.plane_metadata_path(@output_root, n)
179
- count += 1 if write_atomic(path, plane_payload(n, plane_block_ids[n] || []))
180
- end
181
- count
182
- end
183
-
184
- def group_block_ids_by_plane(blocks)
185
- blocks.each_with_object(Hash.new { |h, k| h[k] = [] }) do |block, h|
186
- h[block.plane_number] << block.id
187
- end
188
- end
189
-
190
- def plane_payload(plane_number, block_ids)
191
- name, abbrev = PLANE_TABLE.fetch(plane_number)
192
- range_first = plane_number * 0x10000
193
- range_last = range_first + 0xFFFF
194
- to_pretty_json(
195
- "number" => plane_number,
196
- "name" => name,
197
- "abbrev" => abbrev,
198
- "range_first" => range_first,
199
- "range_last" => range_last,
200
- "block_ids" => block_ids,
201
- )
202
- end
203
-
204
- # ---- Block files -------------------------------------------------
205
-
206
- def write_blocks(blocks)
207
- count = blocks.sum do |block|
208
- block.age = @block_ages[block.id]
209
- path = Paths.block_metadata_path(@output_root, block.id)
210
- write_atomic(path, block_payload(block)) ? 1 : 0
211
- end
212
- count + write_blocks_index(blocks)
213
- end
214
-
215
- def write_blocks_index(blocks)
216
- path = Paths.blocks_index_path(@output_root)
217
- summary = blocks.map do |block|
218
- {
219
- "id" => block.id,
220
- "name" => block.name,
221
- "first_cp" => block.range_first,
222
- "last_cp" => block.range_last,
223
- "plane_number" => block.plane_number,
224
- "age" => @block_ages[block.id],
225
- }
226
- end
227
- write_atomic(path, to_pretty_json(summary)) ? 1 : 0
228
- end
229
-
230
- def block_payload(block)
231
- to_pretty_json(
232
- "id" => block.id,
233
- "name" => block.name,
234
- "range_first" => block.range_first,
235
- "range_last" => block.range_last,
236
- "plane_number" => block.plane_number,
237
- "age" => @block_ages[block.id],
238
- "codepoint_ids" => (@block_codepoint_ids[block.id] || []),
239
- )
240
- end
241
-
242
- # ---- Script files ------------------------------------------------
243
-
244
- def write_scripts(scripts)
245
- count = 0
246
- scripts.group_by(&:code).each do |code, ranges|
247
- next if code.nil? || code.empty?
248
-
249
- path = Paths.script_metadata_path(@output_root, code)
250
- count += 1 if write_atomic(path, script_payload(code, ranges))
251
- end
252
- count
253
- end
254
-
255
- def script_payload(code, ranges)
256
- to_pretty_json(
257
- "code" => code,
258
- "name" => ranges.first&.name,
259
- "range_first" => ranges.map(&:range_first).min,
260
- "range_last" => ranges.map(&:range_last).max,
261
- "codepoint_ids" => (@script_codepoint_ids[code] || []),
262
- )
263
- end
264
-
265
- # ---- Lookup indexes ---------------------------------------------
266
-
267
- def write_indexes
268
- count = 0
269
- count += 1 if write_atomic(Paths.names_index_path(@output_root), to_pretty_json(@names_index))
270
- count += 1 if write_atomic(Paths.labels_index_path(@output_root), to_pretty_json(@labels_index))
271
- count += 1 if write_atomic(codepoint_to_block_path, to_pretty_json(@cp_to_block))
272
- count
273
- end
274
-
275
- def codepoint_to_block_path
276
- Pathname(@output_root).join("index", "codepoint_to_block.json")
277
- end
278
-
279
- # ---- Relationships ----------------------------------------------
280
-
281
- def write_relationships(indices)
282
- RELATIONSHIP_SOURCES.sum do |field, slug|
283
- records = indices.public_send(field)
284
- write_relationship_file(slug, records)
285
- end
286
- end
287
-
288
- def write_relationship_file(slug, records)
289
- return 0 if records.nil? || records.empty?
290
-
291
- path = Pathname(@output_root).join("relationships", "#{slug}.json")
292
- write_atomic(path, relationship_payload(records)) ? 1 : 0
293
- end
294
-
295
- # records is Hash<Integer, Record>, Hash<Integer, Array<Record>>,
296
- # Hash<String, Record>, or Hash<String, Array<Record>>.
297
- # Output: { "U+XXXX" => record.to_yaml_hash, ... } or
298
- # { "U+XXXX" => [record.to_yaml_hash, ...], ... }
299
- def relationship_payload(records)
300
- payload = records.each_with_object({}) do |(key, value), h|
301
- h[key_to_cp_id(key)] = serialize_value(value)
302
- end
303
- to_pretty_json(payload)
304
- end
305
-
306
- # Indices that are keyed by Integer codepoint (most of them) get
307
- # formatted into "U+XXXX". Indices keyed by string ids already
308
- # (cjk_radicals by ideograph_id, standardized_variants by base_id)
309
- # are passed through verbatim.
310
- def key_to_cp_id(key)
311
- key.is_a?(Integer) ? Paths.cp_id(key) : key
312
- end
313
-
314
- def serialize_value(value)
315
- return value.map { |v| serialize_one(v) } if value.is_a?(Array)
316
-
317
- serialize_one(value)
318
- end
319
-
320
- def serialize_one(record)
321
- record.to_yaml_hash
322
- end
323
-
324
- # ---- Enums -------------------------------------------------------
325
-
326
- def write_enums(property_aliases, property_value_aliases)
327
- path = Pathname(@output_root).join("enums.json")
328
- payload = {
329
- "properties" => property_aliases.map(&:to_yaml_hash),
330
- "property_values" => property_value_aliases.map(&:to_yaml_hash),
331
- }
332
- write_atomic(path, to_pretty_json(payload)) ? 1 : 0
333
- end
334
-
335
- # ---- Named sequences --------------------------------------------
336
-
337
- def write_named_sequences(named_sequences)
338
- return 0 if named_sequences.nil? || named_sequences.empty?
339
-
340
- dir = Pathname(@output_root).join("named_sequences")
341
- named_sequences.sum do |ns|
342
- path = dir.join("#{slug_for(ns)}.json")
343
- write_atomic(path, ns.to_json(pretty: true)) ? 1 : 0
344
- end
345
- end
346
-
347
- # Slug derived from the name: downcase, non-alphanumerics → "_".
348
- def slug_for(named_sequence)
349
- named_sequence.name
350
- .downcase
351
- .gsub(/[^a-z0-9]+/, "_")
352
- .gsub(/^_+|_+$/, "")
353
- end
354
-
355
- # ---- Manifest ---------------------------------------------------
356
-
357
- # Fields that define the manifest's semantic content. When these
358
- # match the existing manifest on disk, we preserve the old
359
- # `generated_at` so that re-runs are byte-idempotent (no rewrite
360
- # unless something actually changed).
361
- MANIFEST_CONTENT_KEYS = %w[
362
- ucd_version codepoint_count glyph_count schema_version
363
- ].freeze
364
- private_constant :MANIFEST_CONTENT_KEYS
365
-
366
- def write_manifest(ucd_version:, glyph_count:)
367
- path = Paths.manifest_path(@output_root)
368
- content = {
369
- "ucd_version" => ucd_version,
370
- "codepoint_count" => @codepoint_count,
371
- "glyph_count" => glyph_count,
372
- "schema_version" => "1",
373
- }
374
- ts = preserved_or_new_timestamp(path, content)
375
- payload = content.merge("generated_at" => ts)
376
- write_atomic(path, to_pretty_json(payload)) ? 1 : 0
377
- end
378
-
379
- def preserved_or_new_timestamp(path, content)
380
- existing = read_manifest(path)
381
- return Time.now.utc.iso8601 unless existing
382
-
383
- unchanged = MANIFEST_CONTENT_KEYS.all? { |k| existing[k] == content[k] }
384
- unchanged ? existing["generated_at"] : Time.now.utc.iso8601
385
- end
386
-
387
- def read_manifest(path)
388
- return nil unless path.exist?
389
-
390
- JSON.parse(path.read)
391
- rescue JSON::ParserError
392
- nil
393
- end
394
156
  end
395
157
  end
396
- end
158
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ucode/repo/atomic_writes"
4
+ require "ucode/repo/paths"
5
+
6
+ module Ucode
7
+ module Repo
8
+ module Writers
9
+ # Writes `output/blocks/<ID>.json` for every block plus
10
+ # `output/blocks/index.json` as a summary.
11
+ #
12
+ # One of the eight per-concern writers split out from
13
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
14
+ class BlocksWriter
15
+ include AtomicWrites
16
+
17
+ # @param output_root [Pathname]
18
+ # @param blocks [Array<Ucode::Models::Block>]
19
+ # @param block_codepoint_ids [Hash{String => Array<String>}]
20
+ # block_id → sorted cp_id list, accumulated during the
21
+ # streaming pass
22
+ # @param block_ages [Hash{String => String}] block_id → earliest
23
+ # DerivedAge string; nil entries get written as nil
24
+ def initialize(output_root:, blocks:, block_codepoint_ids:, block_ages:)
25
+ @output_root = output_root
26
+ @blocks = blocks
27
+ @block_codepoint_ids = block_codepoint_ids
28
+ @block_ages = block_ages
29
+ end
30
+
31
+ # @return [Integer] number of files written (one per block plus
32
+ # one for the index)
33
+ def write
34
+ count = @blocks.sum do |block|
35
+ block.age = @block_ages[block.id]
36
+ path = Paths.block_metadata_path(@output_root, block.id)
37
+ write_atomic(path, block_payload(block)) ? 1 : 0
38
+ end
39
+ count + write_blocks_index
40
+ end
41
+
42
+ private
43
+
44
+ def write_blocks_index
45
+ path = Paths.blocks_index_path(@output_root)
46
+ summary = @blocks.map do |block|
47
+ {
48
+ "id" => block.id,
49
+ "name" => block.name,
50
+ "first_cp" => block.range_first,
51
+ "last_cp" => block.range_last,
52
+ "plane_number" => block.plane_number,
53
+ "age" => @block_ages[block.id],
54
+ }
55
+ end
56
+ write_atomic(path, to_pretty_json(summary)) ? 1 : 0
57
+ end
58
+
59
+ def block_payload(block)
60
+ to_pretty_json(
61
+ "id" => block.id,
62
+ "name" => block.name,
63
+ "range_first" => block.range_first,
64
+ "range_last" => block.range_last,
65
+ "plane_number" => block.plane_number,
66
+ "age" => @block_ages[block.id],
67
+ "codepoint_ids" => (@block_codepoint_ids[block.id] || []),
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ucode/repo/atomic_writes"
5
+
6
+ module Ucode
7
+ module Repo
8
+ module Writers
9
+ # Writes `output/enums.json` carrying the full property-alias
10
+ # and property-value-alias tables.
11
+ #
12
+ # One of the eight per-concern writers split out from
13
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
14
+ class EnumsWriter
15
+ include AtomicWrites
16
+
17
+ # @param output_root [Pathname]
18
+ # @param property_aliases [Array<Ucode::Models::PropertyAlias>]
19
+ # @param property_value_aliases [Array<Ucode::Models::PropertyValueAlias>]
20
+ def initialize(output_root:, property_aliases:, property_value_aliases:)
21
+ @output_root = output_root
22
+ @property_aliases = property_aliases
23
+ @property_value_aliases = property_value_aliases
24
+ end
25
+
26
+ # @return [Integer] 1 if written, 0 otherwise
27
+ def write
28
+ path = Pathname(@output_root).join("enums.json")
29
+ payload = {
30
+ "properties" => @property_aliases.map(&:to_yaml_hash),
31
+ "property_values" => @property_value_aliases.map(&:to_yaml_hash),
32
+ }
33
+ write_atomic(path, to_pretty_json(payload)) ? 1 : 0
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ucode/repo/atomic_writes"
5
+ require "ucode/repo/paths"
6
+
7
+ module Ucode
8
+ module Repo
9
+ module Writers
10
+ # Writes the three lookup indexes:
11
+ #
12
+ # output/index/names.json (cp_id → name)
13
+ # output/index/labels.json (cp_id → {name, gc, sc, cc, bc, mir})
14
+ # output/index/codepoint_to_block.json (cp_id → block_id)
15
+ #
16
+ # One of the eight per-concern writers split out from
17
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
18
+ class IndexesWriter
19
+ include AtomicWrites
20
+
21
+ # @param output_root [Pathname]
22
+ # @param names [Hash{String => String}] cp_id → name
23
+ # @param labels [Hash{String => Hash}] cp_id → label fields
24
+ # @param cp_to_block [Hash{String => String}] cp_id → block_id
25
+ def initialize(output_root:, names:, labels:, cp_to_block:)
26
+ @output_root = output_root
27
+ @names = names
28
+ @labels = labels
29
+ @cp_to_block = cp_to_block
30
+ end
31
+
32
+ # @return [Integer] number of index files written (always 3
33
+ # when the directory is reachable)
34
+ def write
35
+ count = 0
36
+ count += 1 if write_atomic(Paths.names_index_path(@output_root),
37
+ to_pretty_json(@names))
38
+ count += 1 if write_atomic(Paths.labels_index_path(@output_root),
39
+ to_pretty_json(@labels))
40
+ count += 1 if write_atomic(codepoint_to_block_path,
41
+ to_pretty_json(@cp_to_block))
42
+ count
43
+ end
44
+
45
+ private
46
+
47
+ def codepoint_to_block_path
48
+ Pathname(@output_root).join("index", "codepoint_to_block.json")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "time"
6
+
7
+ require "ucode/repo/atomic_writes"
8
+ require "ucode/repo/paths"
9
+
10
+ module Ucode
11
+ module Repo
12
+ module Writers
13
+ # Writes `output/manifest.json`. The `generated_at` timestamp is
14
+ # preserved across no-op re-runs (same content keys → keep old
15
+ # timestamp) so the byte content is byte-idempotent.
16
+ #
17
+ # One of the eight per-concern writers split out from
18
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
19
+ class ManifestWriter
20
+ include AtomicWrites
21
+
22
+ # Fields that define the manifest's semantic content. When
23
+ # these match the existing manifest on disk, we preserve the
24
+ # old `generated_at` so re-runs are byte-idempotent.
25
+ CONTENT_KEYS = %w[
26
+ ucd_version codepoint_count glyph_count schema_version
27
+ ].freeze
28
+ private_constant :CONTENT_KEYS
29
+
30
+ SCHEMA_VERSION = "1"
31
+ private_constant :SCHEMA_VERSION
32
+
33
+ # @param output_root [Pathname]
34
+ # @param ucd_version [String]
35
+ # @param codepoint_count [Integer]
36
+ # @param glyph_count [Integer]
37
+ def initialize(output_root:, ucd_version:, codepoint_count:, glyph_count:)
38
+ @output_root = output_root
39
+ @ucd_version = ucd_version
40
+ @codepoint_count = codepoint_count
41
+ @glyph_count = glyph_count
42
+ end
43
+
44
+ # @return [Integer] 1 if written, 0 otherwise
45
+ def write
46
+ path = Paths.manifest_path(@output_root)
47
+ content = {
48
+ "ucd_version" => @ucd_version,
49
+ "codepoint_count" => @codepoint_count,
50
+ "glyph_count" => @glyph_count,
51
+ "schema_version" => SCHEMA_VERSION,
52
+ }
53
+ ts = preserved_or_new_timestamp(path, content)
54
+ payload = content.merge("generated_at" => ts)
55
+ write_atomic(path, to_pretty_json(payload)) ? 1 : 0
56
+ end
57
+
58
+ private
59
+
60
+ def preserved_or_new_timestamp(path, content)
61
+ existing = read_manifest(path)
62
+ return Time.now.utc.iso8601 unless existing
63
+
64
+ unchanged = CONTENT_KEYS.all? { |k| existing[k] == content[k] }
65
+ unchanged ? existing["generated_at"] : Time.now.utc.iso8601
66
+ end
67
+
68
+ def read_manifest(path)
69
+ return nil unless path.exist?
70
+
71
+ JSON.parse(path.read)
72
+ rescue JSON::ParserError
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end