ucode 0.1.0 → 0.2.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/TODO.full/00-README.md +116 -0
  4. data/TODO.full/01-panglyph-vision.md +112 -0
  5. data/TODO.full/02-panglyph-repo-bootstrap.md +184 -0
  6. data/TODO.full/03-panglyph-font-builder.md +201 -0
  7. data/TODO.full/04-panglyph-publish-pipeline.md +126 -0
  8. data/TODO.full/05-ucode-0-1-1-release.md +139 -0
  9. data/TODO.full/06-fontisan-remove-audit.md +142 -0
  10. data/TODO.full/07-fontisan-remove-ucd.md +125 -0
  11. data/TODO.full/08-archive-private-bin-build.md +143 -0
  12. data/TODO.full/09-archive-public-structure.md +164 -0
  13. data/TODO.full/10-fontist-org-woff-glyphs.md +131 -0
  14. data/TODO.full/11-fontist-org-audit-coverage.md +140 -0
  15. data/TODO.full/12-implementation-order.md +216 -0
  16. data/TODO.full/13-fontisan-font-writer-api.md +189 -0
  17. data/TODO.full/14-fontisan-table-writers.md +66 -0
  18. data/TODO.full/15-panglyph-builder-real.md +82 -0
  19. data/TODO.full/16-archive-public-sync-workflows.md +167 -0
  20. data/TODO.full/17-fontist-org-font-picker.md +73 -0
  21. data/TODO.full/18-comprehensive-spec-coverage.md +64 -0
  22. data/TODO.full/19-ucode-0-1-2-patch.md +32 -0
  23. data/TODO.full/20-fontisan-0-2-23-release.md +52 -0
  24. data/TODO.new/00-README.md +30 -0
  25. data/TODO.new/23-universal-glyph-set-source-map.md +312 -0
  26. data/TODO.new/24-universal-glyph-set-build.md +189 -0
  27. data/TODO.new/25-font-audit-against-universal-set.md +195 -0
  28. data/TODO.new/26-missing-glyph-reporter.md +189 -0
  29. data/TODO.new/27-fontist-org-consumer-integration.md +200 -0
  30. data/TODO.new/28-implementation-order-update.md +187 -0
  31. data/TODO.new/29-universal-set-curation-uc17.md +312 -0
  32. data/TODO.new/30-tier1-font-acquisition.md +241 -0
  33. data/TODO.new/31-universal-set-production-build.md +205 -0
  34. data/TODO.new/32-uc17-coverage-matrix.md +165 -0
  35. data/TODO.new/33-specialist-font-acquisition-refresh.md +138 -0
  36. data/TODO.new/34-pillar2-content-stream-correlator.md +147 -0
  37. data/TODO.new/35-universal-set-production-run.md +160 -0
  38. data/TODO.new/36-per-font-coverage-audit.md +145 -0
  39. data/TODO.new/37-coverage-highlight-reporter.md +125 -0
  40. data/TODO.new/38-fontist-org-glyph-consumer.md +141 -0
  41. data/TODO.new/39-implementation-order-update-32-38.md +258 -0
  42. data/TODO.new/40-archive-private-uses-ucode-audit.md +124 -0
  43. data/TODO.new/41-ucode-unicode-archive-bridge.md +160 -0
  44. data/config/specialist_fonts.yml +102 -0
  45. data/config/unicode17_tier1_fonts.yml +42 -0
  46. data/config/unicode17_universal_glyph_set.yml +293 -0
  47. data/lib/ucode/audit/block_aggregator.rb +57 -29
  48. data/lib/ucode/audit/browser/face_page.rb +128 -0
  49. data/lib/ucode/audit/browser/glyph_panel.rb +124 -0
  50. data/lib/ucode/audit/browser/library_page.rb +74 -0
  51. data/lib/ucode/audit/browser/missing_glyph_page.rb +87 -0
  52. data/lib/ucode/audit/browser/template.rb +47 -0
  53. data/lib/ucode/audit/browser/templates/face.css +200 -0
  54. data/lib/ucode/audit/browser/templates/face.html.erb +41 -0
  55. data/lib/ucode/audit/browser/templates/face.js +298 -0
  56. data/lib/ucode/audit/browser/templates/library.css +119 -0
  57. data/lib/ucode/audit/browser/templates/library.html.erb +42 -0
  58. data/lib/ucode/audit/browser/templates/library.js +99 -0
  59. data/lib/ucode/audit/browser/templates/missing_glyph_page.css +119 -0
  60. data/lib/ucode/audit/browser/templates/missing_glyph_page.html.erb +58 -0
  61. data/lib/ucode/audit/browser/templates/missing_glyph_page.js +2 -0
  62. data/lib/ucode/audit/browser.rb +32 -0
  63. data/lib/ucode/audit/context.rb +27 -1
  64. data/lib/ucode/audit/coverage_reference.rb +103 -0
  65. data/lib/ucode/audit/differ.rb +121 -0
  66. data/lib/ucode/audit/emitter/block_emitter.rb +52 -0
  67. data/lib/ucode/audit/emitter/codepoint_emitter.rb +87 -0
  68. data/lib/ucode/audit/emitter/collection_emitter.rb +80 -0
  69. data/lib/ucode/audit/emitter/face_directory.rb +212 -0
  70. data/lib/ucode/audit/emitter/glyph_emitter.rb +48 -0
  71. data/lib/ucode/audit/emitter/index_emitter.rb +149 -0
  72. data/lib/ucode/audit/emitter/library_emitter.rb +96 -0
  73. data/lib/ucode/audit/emitter/paths.rb +312 -0
  74. data/lib/ucode/audit/emitter/plane_emitter.rb +29 -0
  75. data/lib/ucode/audit/emitter/script_emitter.rb +29 -0
  76. data/lib/ucode/audit/emitter.rb +29 -0
  77. data/lib/ucode/audit/extractors/aggregations.rb +31 -2
  78. data/lib/ucode/audit/face_auditor.rb +86 -0
  79. data/lib/ucode/audit/formatters/audit_diff_text.rb +112 -0
  80. data/lib/ucode/audit/formatters/audit_text.rb +411 -0
  81. data/lib/ucode/audit/formatters/color.rb +48 -0
  82. data/lib/ucode/audit/formatters/library_summary_text.rb +98 -0
  83. data/lib/ucode/audit/formatters/text_formatter.rb +83 -0
  84. data/lib/ucode/audit/formatters.rb +23 -0
  85. data/lib/ucode/audit/library_aggregator.rb +86 -0
  86. data/lib/ucode/audit/library_auditor.rb +105 -0
  87. data/lib/ucode/audit/reference_factory.rb +66 -0
  88. data/lib/ucode/audit/release/emitter.rb +152 -0
  89. data/lib/ucode/audit/release/face_card.rb +93 -0
  90. data/lib/ucode/audit/release/formula_audits.rb +50 -0
  91. data/lib/ucode/audit/release/library_index_builder.rb +78 -0
  92. data/lib/ucode/audit/release/manifest_builder.rb +127 -0
  93. data/lib/ucode/audit/release.rb +42 -0
  94. data/lib/ucode/audit/ucd_only_reference.rb +81 -0
  95. data/lib/ucode/audit/universal_set_reference.rb +136 -0
  96. data/lib/ucode/audit.rb +32 -0
  97. data/lib/ucode/cli.rb +366 -41
  98. data/lib/ucode/commands/audit/browser_command.rb +82 -0
  99. data/lib/ucode/commands/audit/collection_command.rb +103 -0
  100. data/lib/ucode/commands/audit/compare_command.rb +188 -0
  101. data/lib/ucode/commands/audit/font_command.rb +140 -0
  102. data/lib/ucode/commands/audit/library_command.rb +87 -0
  103. data/lib/ucode/commands/audit.rb +19 -0
  104. data/lib/ucode/commands/block_feed.rb +73 -0
  105. data/lib/ucode/commands/build.rb +4 -0
  106. data/lib/ucode/commands/canonical_build.rb +137 -0
  107. data/lib/ucode/commands/fetch.rb +49 -15
  108. data/lib/ucode/commands/glyphs.rb +25 -67
  109. data/lib/ucode/commands/lookup.rb +11 -11
  110. data/lib/ucode/commands/parse.rb +7 -5
  111. data/lib/ucode/commands/release.rb +114 -0
  112. data/lib/ucode/commands/universal_set.rb +207 -0
  113. data/lib/ucode/commands.rb +5 -0
  114. data/lib/ucode/coordinator/indices.rb +49 -2
  115. data/lib/ucode/coordinator.rb +138 -5
  116. data/lib/ucode/error.rb +30 -2
  117. data/lib/ucode/fetch/font_fetcher/result.rb +39 -0
  118. data/lib/ucode/fetch/font_fetcher.rb +16 -0
  119. data/lib/ucode/fetch/specialist_font_fetcher.rb +280 -0
  120. data/lib/ucode/fetch.rb +7 -3
  121. data/lib/ucode/glyphs/pipeline.rb +106 -0
  122. data/lib/ucode/glyphs/real_fonts/cmap_cache.rb +74 -0
  123. data/lib/ucode/glyphs/real_fonts.rb +1 -0
  124. data/lib/ucode/glyphs/resolver.rb +62 -0
  125. data/lib/ucode/glyphs/source.rb +48 -0
  126. data/lib/ucode/glyphs/source_builder.rb +61 -0
  127. data/lib/ucode/glyphs/source_config/coverage_assertion.rb +79 -0
  128. data/lib/ucode/glyphs/source_config/gap_report.rb +54 -0
  129. data/lib/ucode/glyphs/source_config.rb +104 -0
  130. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +63 -0
  131. data/lib/ucode/glyphs/sources/pillar3_last_resort.rb +51 -0
  132. data/lib/ucode/glyphs/sources/tier1_real_font.rb +104 -0
  133. data/lib/ucode/glyphs/sources.rb +20 -0
  134. data/lib/ucode/glyphs/universal_set/builder.rb +161 -0
  135. data/lib/ucode/glyphs/universal_set/coverage_report.rb +139 -0
  136. data/lib/ucode/glyphs/universal_set/idempotency.rb +86 -0
  137. data/lib/ucode/glyphs/universal_set/manifest_accumulator.rb +195 -0
  138. data/lib/ucode/glyphs/universal_set/manifest_writer.rb +61 -0
  139. data/lib/ucode/glyphs/universal_set/pre_build_check.rb +197 -0
  140. data/lib/ucode/glyphs/universal_set/validator.rb +204 -0
  141. data/lib/ucode/glyphs/universal_set.rb +45 -0
  142. data/lib/ucode/glyphs.rb +7 -0
  143. data/lib/ucode/models/audit/baseline.rb +6 -0
  144. data/lib/ucode/models/audit/block_summary.rb +7 -0
  145. data/lib/ucode/models/audit/codepoint_provenance.rb +39 -0
  146. data/lib/ucode/models/audit/release_face.rb +42 -0
  147. data/lib/ucode/models/audit/release_formula.rb +33 -0
  148. data/lib/ucode/models/audit/release_manifest.rb +43 -0
  149. data/lib/ucode/models/audit/release_universal_set.rb +37 -0
  150. data/lib/ucode/models/audit.rb +9 -0
  151. data/lib/ucode/models/block.rb +2 -0
  152. data/lib/ucode/models/build_report.rb +109 -0
  153. data/lib/ucode/models/codepoint/glyph.rb +42 -0
  154. data/lib/ucode/models/codepoint.rb +3 -0
  155. data/lib/ucode/models/glyph_source.rb +86 -0
  156. data/lib/ucode/models/glyph_source_map.rb +138 -0
  157. data/lib/ucode/models/specialist_font.rb +70 -0
  158. data/lib/ucode/models/specialist_font_manifest.rb +48 -0
  159. data/lib/ucode/models/unihan_entry.rb +81 -9
  160. data/lib/ucode/models/unihan_field.rb +21 -0
  161. data/lib/ucode/models/universal_set_entry.rb +47 -0
  162. data/lib/ucode/models/universal_set_manifest.rb +78 -0
  163. data/lib/ucode/models/validation_report.rb +99 -0
  164. data/lib/ucode/models.rb +9 -0
  165. data/lib/ucode/parsers/named_sequences.rb +5 -5
  166. data/lib/ucode/parsers/unihan.rb +50 -19
  167. data/lib/ucode/repo/aggregate_writer.rb +82 -288
  168. data/lib/ucode/repo/block_feed_emitter.rb +153 -0
  169. data/lib/ucode/repo/build_report_accumulator.rb +138 -0
  170. data/lib/ucode/repo/build_report_writer.rb +46 -0
  171. data/lib/ucode/repo/build_validator.rb +229 -0
  172. data/lib/ucode/repo/codepoint_writer.rb +50 -1
  173. data/lib/ucode/repo/paths.rb +8 -0
  174. data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
  175. data/lib/ucode/repo/writers/enums_writer.rb +38 -0
  176. data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
  177. data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
  178. data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
  179. data/lib/ucode/repo/writers/planes_writer.rb +82 -0
  180. data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
  181. data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
  182. data/lib/ucode/repo/writers.rb +20 -0
  183. data/lib/ucode/repo.rb +5 -0
  184. data/lib/ucode/version.rb +1 -1
  185. data/schema/block-feed.output.schema.yml +134 -0
  186. metadata +158 -4
  187. data/Gemfile.lock +0 -406
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ require "ucode/models"
7
+ require "ucode/repo/paths"
8
+
9
+ module Ucode
10
+ module Glyphs
11
+ module UniversalSet
12
+ # Thread-safe accumulator that observes the {Builder}'s per-
13
+ # codepoint attempts and produces the final
14
+ # {Ucode::Models::UniversalSetManifest} plus the per-block
15
+ # breakdown the {ManifestWriter} emits as `reports/by_block.json`.
16
+ #
17
+ # Mirrors the {Ucode::Repo::BuildReportAccumulator} pattern from
18
+ # Mode 1: the orchestrating command passes this instance to the
19
+ # builder, which calls {#record_build} (or {#record_skip}) from
20
+ # inside its worker pool. After the drain completes,
21
+ # {#to_manifest} returns the immutable snapshot.
22
+ #
23
+ # == Semantics
24
+ #
25
+ # - `codepoints_assigned` counts every codepoint the builder
26
+ # attempted (passed the block_filter guard).
27
+ # - `codepoints_built` counts codepoints whose resolver returned
28
+ # a glyph.
29
+ # - `codepoints_skipped` counts codepoints that resolved to nil
30
+ # (no tier produced a glyph) — these are the "gaps" the gaps
31
+ # report enumerates.
32
+ # - `codepoints_failed` counts exceptions recorded via
33
+ # {#record_failure}.
34
+ #
35
+ # `by_tier` counts the winning tier per codepoint (one increment
36
+ # per built codepoint). The map uses the wire form ("tier-1",
37
+ # "pillar-1", ...) so the manifest is stable across Ruby symbol
38
+ # changes.
39
+ #
40
+ # `by_block` is a hash keyed by block_id, with built / skipped /
41
+ # failed counters per block. Computed from the codepoint stream
42
+ # the Builder drains — the accumulator reads {CodePoint#block_id}
43
+ # directly. Block ids follow the canonical underscore form.
44
+ class ManifestAccumulator
45
+ TIER_TO_WIRE = {
46
+ tier1: "tier-1",
47
+ pillar1: "pillar-1",
48
+ pillar2: "pillar-2",
49
+ pillar3: "pillar-3",
50
+ }.freeze
51
+ private_constant :TIER_TO_WIRE
52
+
53
+ # @param unicode_version [String]
54
+ # @param ucode_version [String]
55
+ # @param source_config_sha256 [String]
56
+ def initialize(unicode_version:, ucode_version:, source_config_sha256:)
57
+ @unicode_version = unicode_version
58
+ @ucode_version = ucode_version
59
+ @source_config_sha256 = source_config_sha256
60
+ @totals = { codepoints_assigned: 0, codepoints_built: 0,
61
+ codepoints_skipped: 0, codepoints_failed: 0 }
62
+ @by_tier = Hash.new(0)
63
+ @by_block = Hash.new do |h, block_id|
64
+ h[block_id] = { built: 0, skipped: 0, failed: 0 }
65
+ end
66
+ @entries = []
67
+ @gaps = []
68
+ @failures = []
69
+ @mutex = Mutex.new
70
+ end
71
+
72
+ # Observer entry — the builder calls this for every codepoint
73
+ # the resolver produced a glyph for. Records the entry and
74
+ # bumps the built counter + per-tier + per-block rollups.
75
+ #
76
+ # @param codepoint [Ucode::Models::CodePoint]
77
+ # @param result [Ucode::Glyphs::Source::Result] non-nil
78
+ # @param svg [String] the SVG bytes that were written
79
+ # @return [void]
80
+ def record_build(codepoint, result, svg:)
81
+ entry = build_entry(codepoint.cp, result, svg)
82
+ tier_wire = wire_tier(result.tier)
83
+ synchronize do
84
+ @totals[:codepoints_assigned] += 1
85
+ @totals[:codepoints_built] += 1
86
+ @by_tier[tier_wire] += 1
87
+ @by_block[codepoint.block_id][:built] += 1
88
+ @entries << entry
89
+ end
90
+ end
91
+
92
+ # Observer entry — the builder calls this when the resolver
93
+ # returned nil for a codepoint. Counts the attempt and adds
94
+ # it to the gaps list for the gaps report.
95
+ #
96
+ # @param codepoint [Ucode::Models::CodePoint]
97
+ # @return [void]
98
+ def record_skip(codepoint)
99
+ synchronize do
100
+ @totals[:codepoints_assigned] += 1
101
+ @totals[:codepoints_skipped] += 1
102
+ @by_block[codepoint.block_id][:skipped] += 1
103
+ @gaps << codepoint.cp
104
+ end
105
+ end
106
+
107
+ # Record an exception. The builder rescues per-codepoint
108
+ # errors and routes them here so one bad codepoint doesn't
109
+ # abort the run.
110
+ #
111
+ # @param codepoint [Ucode::Models::CodePoint, nil]
112
+ # @param error [StandardError]
113
+ # @return [void]
114
+ def record_failure(codepoint, error)
115
+ synchronize do
116
+ @totals[:codepoints_assigned] += 1 unless codepoint.nil?
117
+ @totals[:codepoints_failed] += 1
118
+ @by_block[codepoint&.block_id][:failed] += 1 unless codepoint.nil?
119
+ @failures << { codepoint: codepoint&.cp,
120
+ block_id: codepoint&.block_id,
121
+ error_class: error.class.name,
122
+ message: error.message }
123
+ end
124
+ end
125
+
126
+ # @return [Ucode::Models::UniversalSetManifest] immutable snapshot
127
+ def to_manifest
128
+ synchronize do
129
+ Ucode::Models::UniversalSetManifest.new(
130
+ unicode_version: @unicode_version,
131
+ ucode_version: @ucode_version,
132
+ generated_at: Time.now.utc.iso8601,
133
+ source_config_sha256: @source_config_sha256,
134
+ totals: Ucode::Models::UniversalSetManifest::Totals.new(@totals),
135
+ by_tier: @by_tier.dup,
136
+ entries: @entries.dup,
137
+ )
138
+ end
139
+ end
140
+
141
+ # @return [Hash{String=>Hash}] per-block built/skipped/failed
142
+ # counts, deep-copied so callers can't mutate accumulator state.
143
+ def by_block
144
+ synchronize do
145
+ @by_block.transform_values(&:dup)
146
+ end
147
+ end
148
+
149
+ # @return [Array<Integer>] codepoints that resolved to nil, sorted
150
+ def gaps
151
+ synchronize { @gaps.sort }
152
+ end
153
+
154
+ # @return [Array<Hash>] recorded failures (each with codepoint,
155
+ # block_id, error_class, message)
156
+ def failures
157
+ synchronize { @failures.dup }
158
+ end
159
+
160
+ private
161
+
162
+ def build_entry(codepoint, result, svg)
163
+ Ucode::Models::UniversalSetEntry.new(
164
+ codepoint: codepoint,
165
+ id: Ucode::Repo::Paths.cp_id(codepoint),
166
+ tier: wire_tier(result.tier),
167
+ source: source_label(result.provenance),
168
+ svg_sha256: sha256(svg),
169
+ svg_size_bytes: svg.bytesize,
170
+ )
171
+ end
172
+
173
+ # Extract the source identifier from a dotted provenance
174
+ # string ("tier-1:noto-sans" -> "noto-sans"). When there's no
175
+ # `:` separator, returns the input verbatim — defensive
176
+ # against malformed provenance.
177
+ def source_label(provenance)
178
+ provenance.to_s.split(":", 2).last || provenance.to_s
179
+ end
180
+
181
+ def sha256(payload)
182
+ Digest::SHA256.hexdigest(payload)
183
+ end
184
+
185
+ def wire_tier(symbol)
186
+ TIER_TO_WIRE.fetch(symbol, symbol.to_s)
187
+ end
188
+
189
+ def synchronize(&)
190
+ @mutex.synchronize(&)
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "json"
5
+
6
+ require "ucode/glyphs/universal_set/idempotency"
7
+
8
+ module Ucode
9
+ module Glyphs
10
+ module UniversalSet
11
+ # Writes the final manifest + reports under the output root.
12
+ #
13
+ # One manifest, three reports:
14
+ #
15
+ # - `manifest.json` — full {Ucode::Models::UniversalSetManifest}.
16
+ # - `reports/by_tier.json` — `by_tier` counts alone (small file
17
+ # for quick "how much of the set is tier 1?" inspection).
18
+ # - `reports/by_block.json` — per-block built/skipped totals,
19
+ # computed from the manifest's entries + the codepoint's
20
+ # block_id (resolved by the Builder).
21
+ # - `reports/gaps.json` — array of codepoint integers that
22
+ # resolved to nil (should be empty for a healthy run).
23
+ #
24
+ # All writes are atomic via {Idempotency} (which includes
25
+ # {Ucode::Repo::AtomicWrites}). Re-running on an unchanged
26
+ # manifest is a no-op modulo `generated_at`.
27
+ class ManifestWriter
28
+ include Idempotency
29
+
30
+ # @param output_root [String, Pathname]
31
+ def initialize(output_root)
32
+ @output_root = Pathname.new(output_root)
33
+ end
34
+
35
+ # Write the manifest + reports atomically.
36
+ #
37
+ # @param manifest [Ucode::Models::UniversalSetManifest]
38
+ # @param by_block [Hash{String=>Hash}] per-block breakdown:
39
+ # `{ "Basic_Latin" => { built: 64, skipped: 0, failed: 0 } }`.
40
+ # Computed by the {Builder}; this writer just serializes it.
41
+ # @param gaps [Array<Integer>] codepoints with no glyph
42
+ # @param failures [Array<Hash>] per-codepoint failures
43
+ # @return [Pathname] path to the written manifest
44
+ def write(manifest, by_block:, gaps:, failures:)
45
+ write_atomic(manifest_path(@output_root), manifest_to_json(manifest))
46
+ write_atomic(by_tier_report_path(@output_root), to_pretty_json(manifest.by_tier))
47
+ write_atomic(by_block_report_path(@output_root), to_pretty_json(by_block))
48
+ write_atomic(gaps_report_path(@output_root),
49
+ to_pretty_json(gaps: gaps, failures: failures))
50
+ manifest_path(@output_root)
51
+ end
52
+
53
+ private
54
+
55
+ def manifest_to_json(manifest)
56
+ manifest.to_json(pretty: true)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/error"
6
+ require "ucode/glyphs/real_fonts/font_locator"
7
+ require "ucode/glyphs/source_config"
8
+ require "ucode/glyphs/source_config/coverage_assertion"
9
+ require "ucode/glyphs/source_config/gap_report"
10
+ require "ucode/models/glyph_source_map"
11
+
12
+ module Ucode
13
+ module Glyphs
14
+ module UniversalSet
15
+ # Report produced by {PreBuildCheck}. Carries the raw findings
16
+ # without deciding whether to abort — that decision lives on
17
+ # the check itself so callers can inspect the report without
18
+ # rescuing.
19
+ PreBuildReport = Struct.new(:source_config_path, :unicode_version,
20
+ :config_loaded, :missing_fonts,
21
+ :coverage_gaps, keyword_init: true) do
22
+ def ok?
23
+ config_loaded && missing_fonts.empty?
24
+ end
25
+ end
26
+
27
+ # Pre-flight validation for a universal-set build. Runs the
28
+ # three checks TODO 31 specifies:
29
+ #
30
+ # 1. **Source config loads cleanly.** `SourceConfig.new(path:)`
31
+ # returns a map without raising, and the file exists.
32
+ # 2. **All fonts present.** Every source in the map resolves to
33
+ # a file on disk (kind=path) or via fontist's index
34
+ # (kind=fontist, install: false). Missing fonts are listed.
35
+ # 3. **Coverage assertion runs.** TODO 29's CoverageAssertion
36
+ # walks every assigned codepoint; gaps are surfaced but do
37
+ # not abort (expected for residual curation cases).
38
+ #
39
+ # The check raises {Ucode::UniversalSetPreBuildError} when
40
+ # `missing_fonts` is non-empty or the config fails to load. The
41
+ # CLI catches this and renders the failing checks; the build
42
+ # never starts with known-bad inputs.
43
+ class PreBuildCheck
44
+ # @param source_config_path [String, Pathname]
45
+ # @param database [Ucode::Database] open database for the
46
+ # target Unicode version. Used by CoverageAssertion.
47
+ # @param cmaps [#covers?] defaults to RealFonts::CmapCache.
48
+ # Injectable for testing (e.g. StaticCmaps).
49
+ # @param font_locator [#locate] defaults to a fresh
50
+ # FontLocator. Injectable for testing.
51
+ def initialize(source_config_path:, database:, cmaps: nil,
52
+ font_locator: RealFonts::FontLocator.new)
53
+ @source_config_path = Pathname.new(source_config_path)
54
+ @database = database
55
+ @cmaps = cmaps || RealFonts::CmapCache.new
56
+ @font_locator = font_locator
57
+ end
58
+
59
+ # @raise [Ucode::UniversalSetPreBuildError] when missing_fonts
60
+ # is non-empty or the source config fails to load.
61
+ # @return [PreBuildReport]
62
+ def call
63
+ report = build_report
64
+ unless report.ok?
65
+ raise Ucode::UniversalSetPreBuildError.new(
66
+ "pre-build validation failed",
67
+ context: {
68
+ source_config_path: @source_config_path.to_s,
69
+ missing_fonts: report.missing_fonts,
70
+ config_loaded: report.config_loaded,
71
+ },
72
+ )
73
+ end
74
+
75
+ report
76
+ end
77
+
78
+ private
79
+
80
+ def build_report
81
+ config, loaded = load_config
82
+ missing = loaded ? collect_missing_fonts(config.map) : []
83
+ gaps = loaded ? run_coverage_assertion(config.map) : empty_gap_report
84
+
85
+ PreBuildReport.new(
86
+ source_config_path: @source_config_path.to_s,
87
+ unicode_version: @database.ucd_version,
88
+ config_loaded: loaded,
89
+ missing_fonts: missing,
90
+ coverage_gaps: gaps,
91
+ )
92
+ end
93
+
94
+ def load_config
95
+ config = SourceConfig.new(path: @source_config_path)
96
+ [config, config.exist?]
97
+ rescue StandardError => e
98
+ warn_with(e)
99
+ [nil, false]
100
+ end
101
+
102
+ def collect_missing_fonts(source_map)
103
+ unique_sources(source_map).each_with_object([]) do |src, acc|
104
+ acc.concat(findings_for(src))
105
+ end
106
+ end
107
+
108
+ # All distinct sources referenced by the map, typed. Block-
109
+ # specific sources plus the top-level defaults. Deduplicated
110
+ # by (kind, label, path) so a font referenced by N blocks is
111
+ # only checked once.
112
+ def unique_sources(source_map)
113
+ block_sources = source_map.block_ids.flat_map do |block_id|
114
+ source_map.sources_for(block_id)
115
+ end
116
+ (block_sources + source_map.default_sources).uniq do |src|
117
+ [src.kind, src.label, src.path]
118
+ end
119
+ end
120
+
121
+ # Resolve one source against the filesystem / fontist index.
122
+ # Returns an array of findings (empty when the source is OK).
123
+ def findings_for(src)
124
+ kind = safe_kind(src)
125
+ case kind
126
+ when :path
127
+ path_resolves?(src.path) ? [] : [missing_path(src)]
128
+ when :fontist, :system
129
+ fontist_resolves?(src.label) ? [] : [missing_fontist(src, kind)]
130
+ when nil
131
+ [malformed_entry(src)]
132
+ end
133
+ end
134
+
135
+ # Returns the source's kind as a symbol, or nil when the
136
+ # entry is malformed (no `kind` field). A nil kind is itself
137
+ # a finding — every entry must declare its kind.
138
+ def safe_kind(src)
139
+ src.kind.nil? || src.kind.empty? ? nil : src.kind.to_sym
140
+ end
141
+
142
+ def path_resolves?(raw_path)
143
+ return false if raw_path.nil? || raw_path.empty?
144
+
145
+ expanded = File.expand_path(raw_path)
146
+ Dir.glob(expanded).any? { |p| File.file?(p) }
147
+ end
148
+
149
+ def fontist_resolves?(label)
150
+ return false if label.nil? || label.empty?
151
+
152
+ result = @font_locator.locate(label, install: false)
153
+ !result.nil? && !result.path.nil?
154
+ rescue StandardError
155
+ false
156
+ end
157
+
158
+ def missing_path(src)
159
+ { kind: "path", label: src.label, spec: src.path,
160
+ reason: "file not found at #{src.path.inspect}" }
161
+ end
162
+
163
+ def missing_fontist(src, kind)
164
+ { kind: kind.to_s, label: src.label, spec: src.label,
165
+ reason: "fontist could not resolve formula #{src.label.inspect}" }
166
+ end
167
+
168
+ def malformed_entry(src)
169
+ { kind: "(missing)", label: src.label,
170
+ spec: src.path || src.label,
171
+ reason: "source entry has no `kind` field — must be fontist, path, or system" }
172
+ end
173
+
174
+ def run_coverage_assertion(source_map)
175
+ SourceConfig::CoverageAssertion.new(
176
+ source_map: source_map, database: @database, cmaps: @cmaps,
177
+ ).call
178
+ end
179
+
180
+ def empty_gap_report
181
+ SourceConfig::GapReport.new(
182
+ unicode_version: @database.ucd_version,
183
+ generated_at: Time.now.utc.iso8601,
184
+ gaps_by_block: {}.freeze,
185
+ total_gaps: 0,
186
+ )
187
+ end
188
+
189
+ def warn_with(error)
190
+ Ucode.configuration.logger&.warn do
191
+ "pre-build: source config failed to load: #{error.class}: #{error.message}"
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "time"
6
+
7
+ require "ucode/models"
8
+ require "ucode/glyphs/universal_set/idempotency"
9
+
10
+ module Ucode
11
+ module Glyphs
12
+ module UniversalSet
13
+ # Post-build validator for a universal-set build (TODO 31 §Post-
14
+ # build validation). Walks the manifest + glyphs directory and
15
+ # runs the four structural checks:
16
+ #
17
+ # 1. `manifest_loadable` — `manifest.json` parses via
18
+ # `Ucode::Models::UniversalSetManifest.from_hash`.
19
+ # 2. `glyph_files_present` — every entry has a corresponding
20
+ # `glyphs/<id>.svg` on disk.
21
+ # 3. `totals_reconcile` — manifest totals match the actual
22
+ # entry counts (`built == entries.size`).
23
+ # 4. `provenance_complete` — every entry has non-empty `tier`
24
+ # and `source`.
25
+ #
26
+ # Tofu (pillar-3) investigation and per-tier / per-block
27
+ # breakdowns live in {CoverageReport} — those are coverage
28
+ # questions, not structural ones. The idempotency check (TODO 31
29
+ # §5) is exercised by re-running the build, not by reading
30
+ # on-disk state.
31
+ #
32
+ # The validator is stateless from the outside: one call to
33
+ # {#validate} walks the manifest, builds a
34
+ # {Ucode::Models::ValidationReport}, and writes it atomically to
35
+ # `<output_root>/reports/validation.json`. Safe to re-run.
36
+ class Validator
37
+ include Idempotency
38
+
39
+ CHECK_MANIFEST = "manifest_loadable"
40
+ CHECK_GLYPHS = "glyph_files_present"
41
+ CHECK_TOTALS = "totals_reconcile"
42
+ CHECK_PROVENANCE = "provenance_complete"
43
+ ALL_CHECKS = [
44
+ CHECK_MANIFEST, CHECK_GLYPHS, CHECK_TOTALS, CHECK_PROVENANCE
45
+ ].freeze
46
+ private_constant :ALL_CHECKS
47
+
48
+ # @param output_root [String, Pathname] directory holding
49
+ # `manifest.json` + `glyphs/` + `reports/`.
50
+ # @param unicode_version [String, nil] stamped onto the report;
51
+ # nil falls back to the manifest's recorded version.
52
+ def initialize(output_root, unicode_version: nil)
53
+ @output_root = Pathname.new(output_root)
54
+ @unicode_version = unicode_version
55
+ end
56
+
57
+ # Walk the manifest + glyphs dir, run all checks, emit
58
+ # `reports/validation.json`. Returns the structured outcome.
59
+ #
60
+ # @return [Hash] { report:, report_path:, passed:, manifest_loaded: }
61
+ def validate
62
+ manifest, manifest_failures = load_manifest
63
+ entries = manifest ? manifest.entries : []
64
+
65
+ findings = manifest_failures.dup
66
+ if manifest
67
+ findings.concat(check_glyph_files(entries))
68
+ findings.concat(check_totals(manifest))
69
+ findings.concat(check_provenance(entries))
70
+ end
71
+
72
+ report = build_report(entries, findings, manifest)
73
+ report_path = write_report(report)
74
+ {
75
+ report: report,
76
+ report_path: report_path,
77
+ passed: report.totals.failures.zero?,
78
+ manifest_loaded: !manifest.nil?,
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ def load_manifest
85
+ path = manifest_path(@output_root)
86
+ unless path.exist?
87
+ return [nil, [make_failure(CHECK_MANIFEST,
88
+ "manifest.json not found at #{path}")]]
89
+ end
90
+
91
+ hash = JSON.parse(path.read)
92
+ model = Ucode::Models::UniversalSetManifest.from_hash(hash)
93
+ [model, []]
94
+ rescue JSON::ParserError => e
95
+ [nil, [make_failure(CHECK_MANIFEST,
96
+ "manifest JSON parse failed: #{e.message}")]]
97
+ rescue StandardError => e
98
+ [nil, [make_failure(CHECK_MANIFEST,
99
+ "manifest deserialization failed: #{e.class}: #{e.message}")]]
100
+ end
101
+
102
+ def check_glyph_files(entries)
103
+ entries.each_with_object([]) do |entry, acc|
104
+ path = glyph_path(@output_root, entry.id)
105
+ next if path.exist?
106
+
107
+ acc << make_failure(CHECK_GLYPHS,
108
+ "missing glyph file at #{path}",
109
+ codepoint: entry.codepoint)
110
+ end
111
+ end
112
+
113
+ def check_totals(manifest)
114
+ entries_size = manifest.entries.size
115
+ built = manifest.totals.codepoints_built
116
+ return [] if built == entries_size
117
+
118
+ [make_failure(CHECK_TOTALS,
119
+ "totals.codepoints_built=#{built} but entries.size=#{entries_size}")]
120
+ end
121
+
122
+ def check_provenance(entries)
123
+ entries.each_with_object([]) do |entry, acc|
124
+ acc.concat(provenance_findings_for(entry))
125
+ end
126
+ end
127
+
128
+ def provenance_findings_for(entry)
129
+ findings = []
130
+ if entry.tier.nil? || entry.tier.empty?
131
+ findings << make_failure(CHECK_PROVENANCE, "entry has no tier",
132
+ codepoint: entry.codepoint)
133
+ end
134
+ if entry.source.nil? || entry.source.empty?
135
+ findings << make_failure(CHECK_PROVENANCE, "entry has no source",
136
+ codepoint: entry.codepoint)
137
+ end
138
+ findings
139
+ end
140
+
141
+ def build_report(entries, findings, manifest)
142
+ checks = ALL_CHECKS.map do |name|
143
+ build_check_summary(name, findings, entries.size, manifest)
144
+ end
145
+
146
+ Ucode::Models::ValidationReport.new(
147
+ unicode_version: (@unicode_version || manifest&.unicode_version).to_s,
148
+ generated_at: Time.now.utc.iso8601,
149
+ totals: Ucode::Models::ValidationReport::Totals.new(
150
+ codepoints_checked: entries.size,
151
+ failures: findings.length,
152
+ checks_run: checks.count { |c| c.status != "skipped" },
153
+ checks_passed: checks.count { |c| c.status == "passed" },
154
+ ),
155
+ checks: checks,
156
+ failures: findings,
157
+ )
158
+ end
159
+
160
+ def build_check_summary(name, findings, entries_size, manifest)
161
+ count = findings.count { |f| f.check == name }
162
+ status = check_status(name, count, manifest)
163
+ total = check_total(name, entries_size, manifest)
164
+
165
+ Ucode::Models::ValidationReport::CheckSummary.new(
166
+ name: name,
167
+ status: status,
168
+ total: total,
169
+ failures: count,
170
+ )
171
+ end
172
+
173
+ def check_status(name, count, manifest)
174
+ return "skipped" if manifest.nil? && name != CHECK_MANIFEST
175
+
176
+ count.zero? ? "passed" : "failed"
177
+ end
178
+
179
+ def check_total(name, entries_size, manifest)
180
+ return 1 if name == CHECK_MANIFEST
181
+ return 0 if manifest.nil?
182
+
183
+ entries_size
184
+ end
185
+
186
+ def write_report(report)
187
+ path = @output_root.join(REPORTS_DIR, "validation.json")
188
+ path.dirname.mkpath
189
+ write_atomic(path, report.to_json(pretty: true))
190
+ path
191
+ end
192
+
193
+ def make_failure(check, message, codepoint: nil)
194
+ Ucode::Models::ValidationReport::Failure.new(
195
+ codepoint: codepoint,
196
+ block: nil,
197
+ check: check,
198
+ message: message,
199
+ )
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end