suma 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f9f2ba538c3f9515549875292447ece09cc661afd3357af6b2662052afeb8aa
4
- data.tar.gz: a8fbfe210cb3d5e112ef03632c8f93678a02a59c307bb2407491dae30471d455
3
+ metadata.gz: f6a70a78b69206429eb11f3798c6c7f6e0fcfb2a3c46fd2dcea9e605b9ed230f
4
+ data.tar.gz: 8ba4c119da7747d212a4e1e85d13a4cbf0191cc780566f4f8a0fbca0529eb91a
5
5
  SHA512:
6
- metadata.gz: b9b4920a4a4961319ac8ee67abccf38557b41d19f12d579711f20310429af4a9532be3204e11384ecb52fe6a22db82967fa7afd7a24bca15fb5f77357a0bdffe
7
- data.tar.gz: 1a03d235c43500e7361fe8002cfb877944285a608cca4ee8be5edde1904cccb6fb0d7cda2df0b93b1f3a56574ff642651f1461bbda8f0c0f5875fdec7ca56ab0
6
+ metadata.gz: 2b695befb4db91a60adbb4b43739367e5187d9e07b4c97610b0843886cfbed4bc53ce6ac7ce43764fa27e9f6d9c46db9b57dfc2e298944429524b9fe7abbdd4b
7
+ data.tar.gz: d8c3bc865349f54b50425b130f527b863442d43a50cdafe820a0fa37dad103300b8cfd2e0c3fbf5304039e1af592db2b8d1cd03f3e0bbe8db7ccce1f55f9f9d4
@@ -14,21 +14,17 @@ on:
14
14
  repository_dispatch:
15
15
  types: [ do-release ]
16
16
 
17
+ # Least-privilege ceiling for the called rubygems-release.yml: its release job
18
+ # needs contents:write (git tag push) and id-token:write (OIDC Trusted Publishing).
19
+ permissions:
20
+ contents: write
21
+ id-token: write
22
+
17
23
  jobs:
18
24
  release:
19
25
  uses: metanorma/ci/.github/workflows/rubygems-release.yml@main
20
26
  with:
21
27
  next_version: ${{ github.event.inputs.next_version }}
22
- # Explicit release_command needed since metanorma/ci#314 deprecated
23
- # `bundler_cache` (hardcoded to false) — the reusable's release job no
24
- # longer runs `bundle install` implicitly via ruby/setup-ruby, so the
25
- # default `bundle exec rake release` fires against uninstalled gems.
26
- # Surfaced by kwkwan on metanorma-plugin-lutaml#285. Deeper fix
27
- # (bundle install inside rubygems-release.yml's release job) tracked
28
- # separately.
29
- release_command: |
30
- bundle install
31
- bundle exec rake release
32
28
  secrets:
33
29
  rubygems-api-key: ${{ secrets.METANORMA_CI_RUBYGEMS_API_KEY }}
34
30
  pat_token: ${{ secrets.METANORMA_CI_PAT_TOKEN }}
data/Gemfile CHANGED
@@ -6,13 +6,17 @@ source "https://rubygems.org"
6
6
  gemspec
7
7
 
8
8
  gem "canon"
9
- # Staged build (suma#94) needs the preserve_unresolved: / artifact_store_dir: /
10
- # reinflate: render options from metanorma#578 (metanorma 2.5.0). Point at the
11
- # feature branch until it is released; drop this once 2.5.0 is on rubygems.
12
- gem "metanorma", github: "metanorma/metanorma",
13
- branch: "feature/collection-incremental-resumable"
9
+ # metanorma resolves from the gemspec constraint (~> 2.3) to the released gem.
10
+ # The staged-build render options (preserve_unresolved:/artifact_store_dir:/
11
+ # reinflate:, metanorma#578) landed in metanorma 2.5.0, now on rubygems, so the
12
+ # former feature-branch override (feature/collection-incremental-resumable) is
13
+ # dropped: CI builds against a stable released metanorma, not a floating branch.
14
+ # Flavor gem needed to compile the dummy-collection integration fixture
15
+ # (spec/fixtures/dummy_collection, suma#107).
16
+ gem "metanorma-iso"
14
17
  # gem "metanorma-plugin-lutaml", github: "metanorma/metanorma-plugin-lutaml", branch: "main"
15
18
  # gem "metanorma-standoc", github: "metanorma/metanorma-standoc", branch: "main"
19
+ # gem "expressir", github: "lutaml/expressir", branch: "main"
16
20
  gem "nokogiri"
17
21
  gem "openssl", "~> 3.0"
18
22
  gem "rake"
@@ -21,3 +25,25 @@ gem "rubocop"
21
25
  gem "rubocop-performance"
22
26
  gem "rubocop-rake"
23
27
  gem "rubocop-rspec"
28
+ # Expressir >= 2.4.4 passes the lutaml-model `serialize:` mapping
29
+ # kwarg, which needs lutaml-model >= 0.8.50, which needs rubyzip ~> 3,
30
+ # which needs glossarist >= 2.14, which needs relaton ~> 3.0.0.pre.alpha
31
+ # — a chain no RELEASED metanorma accepts. Left unpinned, the resolver
32
+ # lands on expressir 2.4.5 (whose gemspec under-declares its
33
+ # lutaml-model floor) + lutaml-model 0.8.29, a pair that crashes at
34
+ # load with `unknown keyword: :serialize`. Pin the last released
35
+ # expressir that works on this chain; lift once a released metanorma
36
+ # accepts relaton 3.
37
+ gem "expressir", "2.4.3"
38
+
39
+ # Metanorma 2.5.5 was released referencing Metanorma::Core::Flavors,
40
+ # which only exists in unreleased metanorma-core; its gemspec
41
+ # under-declares the metanorma-core floor, so fresh resolves crash at
42
+ # Metanorma::Collection.parse. Stay on the last consistent release
43
+ # until metanorma-core ships.
44
+ gem "metanorma", "< 2.5.5"
45
+
46
+ # metanorma-plugin-lutaml 0.7.52+ calls Expressir::Express::LazyRepository
47
+ # (expressir >= 2.4.20) but its gemspec still allows expressir >= 2.3.5,
48
+ # breaking builds on the pinned expressir line above.
49
+ gem "metanorma-plugin-lutaml", "< 0.7.52"
@@ -10,7 +10,7 @@ module Suma
10
10
  desc "convert_jsdai XML_FILE IMAGE_FILE OUTPUT_DIR",
11
11
  "Convert JSDAI XML and image files to SVG and EXP files"
12
12
 
13
- # rubocop:disable Metrics/MethodLength
13
+ # rubocop:disable-next Metrics/MethodLength
14
14
  def convert_jsdai(xml_file, image_file, output_dir)
15
15
  xml_file = File.expand_path(xml_file)
16
16
  image_file = File.expand_path(image_file)
@@ -34,11 +34,10 @@ module Suma
34
34
 
35
35
  run(xml_file, image_file, output_dir)
36
36
  end
37
- # rubocop:enable Metrics/MethodLength
38
37
 
39
38
  private
40
39
 
41
- # rubocop:disable Metrics/MethodLength
40
+ # rubocop:disable-next Metrics/MethodLength
42
41
  def run(xml_file, image_file, output_dir)
43
42
  FileUtils.mkdir_p(output_dir)
44
43
 
@@ -57,7 +56,6 @@ module Suma
57
56
 
58
57
  puts "Conversion complete."
59
58
  end
60
- # rubocop:enable Metrics/MethodLength
61
59
  end
62
60
  end
63
61
  end
@@ -10,6 +10,9 @@ module Suma
10
10
  # indexing, validation) lives in the deep module and is reachable
11
11
  # from specs without invoking Thor.
12
12
  class Validate < Thor
13
+ def self.exit_on_failure?
14
+ true
15
+ end
13
16
  desc "links SCHEMAS_FILE DOCUMENTS_PATH [OUTPUT_FILE]",
14
17
  "Extract and validate express links without creating intermediate file"
15
18
  def links(schemas_file = "schemas-srl.yml",
@@ -22,6 +25,52 @@ module Suma
22
25
  ).call
23
26
  puts LinkValidation.generate_summary(result)
24
27
  end
28
+
29
+ desc "mappings DOCUMENTS_PATH",
30
+ "Validate module mapping.yaml links and reference paths " \
31
+ "against the parsed module corpus; only NEW drift " \
32
+ "(not in the baseline) fails"
33
+ option :baseline, type: :string, default: nil,
34
+ desc: "Baseline YAML path (default: " \
35
+ "mapping-drift-baseline.yml under DOCUMENTS_PATH)"
36
+ option :write_baseline, type: :boolean, default: false,
37
+ desc: "Write the scanned drift as the new " \
38
+ "baseline instead of checking"
39
+ def mappings(documents_path = "documents")
40
+ drift = MappingDrift.new(documents_path)
41
+ path = options[:baseline] || default_baseline_path(documents_path)
42
+
43
+ return write_baseline(drift, path) if options[:write_baseline]
44
+
45
+ assert_baseline!(path)
46
+ fail_on_new_drift(*drift.check(path), path)
47
+ end
48
+
49
+ private
50
+
51
+ def default_baseline_path(documents_path)
52
+ File.join(documents_path, "mapping-drift-baseline.yml")
53
+ end
54
+
55
+ def assert_baseline!(path)
56
+ return if File.file?(path)
57
+
58
+ raise Thor::Error, "no baseline at #{path} — generate one with " \
59
+ "--write-baseline and commit it"
60
+ end
61
+
62
+ def fail_on_new_drift(report, fresh, path)
63
+ say "#{report.entries.size} drift entr(ies), #{fresh.size} new vs #{path}"
64
+ return if fresh.empty?
65
+
66
+ raise Thor::Error, "#{fresh.size} new mapping drift:\n#{fresh.join("\n")}"
67
+ end
68
+
69
+ def write_baseline(drift, path)
70
+ report = drift.scan
71
+ File.write(path, report.to_yaml)
72
+ say "baseline written: #{path} (#{report.entries.size} entries)"
73
+ end
25
74
  end
26
75
  end
27
76
  end
@@ -64,7 +64,7 @@ module Suma
64
64
  end
65
65
 
66
66
  def to_plain
67
- parsed.to_s(no_remarks: true)
67
+ parsed.to_s
68
68
  end
69
69
 
70
70
  def filename_plain
@@ -92,7 +92,7 @@ module Suma
92
92
 
93
93
  FileUtils.mkdir_p(File.dirname(filename_plain))
94
94
 
95
- content = with_annotations ? parsed.to_s(no_remarks: false) : to_plain
95
+ content = with_annotations ? parsed.format(no_remarks: false) : to_plain
96
96
  File.write(filename_plain, content)
97
97
  end
98
98
 
@@ -12,7 +12,7 @@ module Suma
12
12
  @xml_file = xml_file
13
13
  end
14
14
 
15
- # rubocop:disable Metrics/MethodLength
15
+ # rubocop:disable-next Metrics/MethodLength
16
16
  def to_svg
17
17
  width, height = @image.dimensions
18
18
  svg_parts = []
@@ -46,9 +46,8 @@ module Suma
46
46
  svg_parts << "\n\t\t</svg>"
47
47
  svg_parts.join
48
48
  end
49
- # rubocop:enable Metrics/MethodLength
50
49
 
51
- # rubocop:disable Metrics/MethodLength
50
+ # rubocop:disable-next Metrics/MethodLength
52
51
  def to_exp
53
52
  basename = File.basename(@xml_file, ".xml")
54
53
  schema_name = extract_schema_name(basename)
@@ -73,11 +72,10 @@ module Suma
73
72
  exp_parts << "\n*)\n"
74
73
  exp_parts.join
75
74
  end
76
- # rubocop:enable Metrics/MethodLength
77
75
 
78
76
  private
79
77
 
80
- # rubocop:disable Metrics/MethodLength
78
+ # rubocop:disable-next Metrics/MethodLength
81
79
  def generate_shape_element(area)
82
80
  shape_attrs = []
83
81
  shape_attrs << 'onmouseout="this.style.opacity=0" '
@@ -108,7 +106,6 @@ module Suma
108
106
  "<rect #{shape_attrs.join}"
109
107
  end
110
108
  end
111
- # rubocop:enable Metrics/MethodLength
112
109
 
113
110
  def parse_rect_coords(coords_str)
114
111
  parts = coords_str.split(",").map(&:to_i)
@@ -140,7 +137,7 @@ module Suma
140
137
  end
141
138
  end
142
139
 
143
- # rubocop:disable Metrics/MethodLength
140
+ # rubocop:disable-next Metrics/MethodLength
144
141
  def extract_schema_name(basename)
145
142
  # For module schemas like "armexpg1" with module="activity"
146
143
  # Result should be "Activity_arm"
@@ -159,9 +156,8 @@ module Suma
159
156
  basename.sub(/expg\d+$/, "")
160
157
  end
161
158
  end
162
- # rubocop:enable Metrics/MethodLength
163
159
 
164
- # rubocop:disable Metrics/MethodLength
160
+ # rubocop:disable-next Metrics/MethodLength
165
161
  def extract_target_from_href(href)
166
162
  # Extract the target from href
167
163
  # Type 1: "../../resources/action_schema/action_schema.xml#action_schema.as_name_attribute_select"
@@ -201,7 +197,6 @@ module Suma
201
197
  href
202
198
  end
203
199
  end
204
- # rubocop:enable Metrics/MethodLength
205
200
  end
206
201
  end
207
202
  end
@@ -58,7 +58,7 @@ module Suma
58
58
  end
59
59
  end
60
60
 
61
- # rubocop:disable Metrics/MethodLength
61
+ # rubocop:disable-next Metrics/MethodLength
62
62
  def extract_jpeg_dimensions
63
63
  # Read JPEG file to extract dimensions
64
64
  # JPEG uses markers, we look for SOF (Start of Frame) markers
@@ -90,7 +90,6 @@ module Suma
90
90
  end
91
91
  end
92
92
  end
93
- # rubocop:enable Metrics/MethodLength
94
93
  end
95
94
  end
96
95
  end
@@ -127,13 +127,28 @@ module Suma
127
127
  def validate_links(schemas_config, links_by_file)
128
128
  paths_by_id = schemas_config.schemas.to_h { |s| [s.id, s.path] }
129
129
  progress.start("Loading schemas", paths_by_id.size)
130
- repo = Expressir::Express::Parser.from_files(paths_by_id.values) do |*_args|
131
- progress.increment
132
- end
130
+ repo = load_repository(paths_by_id.values)
133
131
  index = SchemaIndex.new(repo)
134
132
  LinkValidator.new(index).validate(links_by_file)
135
133
  end
136
134
 
135
+ # Compiled-set warm start (expressir TODO.suma-improvements/04),
136
+ # opt-in via SUMA_COMPILED_SET=1: parse the closure once into an
137
+ # EXSCS1 artifact beside the output and hydrate from it on
138
+ # subsequent runs.
139
+ def load_repository(paths)
140
+ return Expressir::Express::Parser.from_files(paths) unless ENV["SUMA_COMPILED_SET"] == "1"
141
+
142
+ dir = output_file ? output_file.dirname : Pathname.new(Dir.pwd)
143
+ set_path = File.join(dir, "schema-closure.exscs")
144
+ if File.file?(set_path)
145
+ return Expressir::Express::LazyRepository.new(set_path).to_eager
146
+ end
147
+
148
+ FileUtils.mkdir_p(dir)
149
+ Expressir::Express::Parser.from_files(paths, compiled_set: set_path)
150
+ end
151
+
137
152
  def write_summary(result)
138
153
  FileUtils.mkdir_p(output_file.dirname)
139
154
  File.write(output_file, self.class.generate_summary(result))
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "expressir"
5
+
6
+ module Suma
7
+ # Mapping validation surfaced in builds (expressir
8
+ # TODO.suma-improvements/05): validate every module mapping.yaml —
9
+ # its <<express:...>> links and reference paths — against the parsed
10
+ # module corpus, and compare the drift against a committed baseline
11
+ # so only NEW drift fails a build.
12
+ #
13
+ # Owns: module discovery, the shared repository parse, per-module
14
+ # validation, and the baseline comparison. Does not own: presentation
15
+ # and exit codes (the CLI adapter), or the validation rules
16
+ # (Expressir::Mapping does).
17
+ class MappingDrift
18
+ # One drift finding; +key+ is the stable identity compared across
19
+ # runs: "<module path>|<kind>|<detail>".
20
+ class Entry < Lutaml::Model::Serializable
21
+ attribute :key, :string
22
+
23
+ key_value do
24
+ map "key", to: :key
25
+ end
26
+ end
27
+
28
+ # Serializable drift report / baseline (same wire shape).
29
+ class Report < Lutaml::Model::Serializable
30
+ attribute :entries, Entry, collection: true
31
+
32
+ key_value do
33
+ map "entries", to: :entries
34
+ end
35
+
36
+ def keys
37
+ entries.map(&:key)
38
+ end
39
+ end
40
+
41
+ MAPPING_GLOB = "**/mapping.yaml"
42
+ MODULE_SCHEMAS = %w[arm.exp mim.exp].freeze
43
+ private_constant :MAPPING_GLOB, :MODULE_SCHEMAS
44
+
45
+ attr_reader :documents_path, :stepmod
46
+
47
+ # +stepmod+ overrides where closure schema resolution looks up
48
+ # USE/REFERENCE dependencies (STEPmod `schemas/**/<name>.exp`
49
+ # layout); it defaults to +documents_path+ itself.
50
+ def initialize(documents_path, stepmod: nil)
51
+ @documents_path = Pathname.new(documents_path).expand_path
52
+ @stepmod = stepmod
53
+ end
54
+
55
+ # Every module mapping under documents_path that has arm/mim
56
+ # schemas next to it.
57
+ def discover
58
+ Dir[documents_path.join(MAPPING_GLOB).to_s].filter_map do |mapping|
59
+ schemas = module_schemas(File.dirname(mapping))
60
+ { mapping: mapping, schemas: schemas } if schemas.any?
61
+ end
62
+ end
63
+
64
+ # Validate every discovered module against one shared repository.
65
+ # @return [Report]
66
+ def scan
67
+ modules = discover
68
+ return Report.new(entries: []) if modules.empty?
69
+
70
+ paths, gaps = closure(modules)
71
+ repository = Expressir::Express::Parser.from_files(paths)
72
+ Report.new(entries: gaps + modules.flat_map do |mod|
73
+ drift_for(mod, repository)
74
+ end)
75
+ end
76
+
77
+ # Scan and compare against the baseline file.
78
+ # @return [[Report, Array<String>]] the full report and the keys
79
+ # not present in the baseline (the NEW drift).
80
+ def check(baseline_path)
81
+ known = Report.from_yaml(File.read(baseline_path)).keys
82
+ report = scan
83
+ [report, report.keys - known]
84
+ end
85
+
86
+ private
87
+
88
+ def module_schemas(dir)
89
+ MODULE_SCHEMAS
90
+ .map { |name| File.join(dir, name) }
91
+ .select { |path| File.file?(path) }
92
+ end
93
+
94
+ # The repository covers every module schema plus its transitive
95
+ # USE/REFERENCE closure — reference paths name resource-schema
96
+ # types the modules themselves never declare. A schema the corpus
97
+ # does not carry (e.g. external PLIB templates) becomes a closure
98
+ # gap entry instead of aborting the whole scan; the affected
99
+ # module still validates against its own schemas.
100
+ def closure(modules)
101
+ paths = []
102
+ gaps = []
103
+ modules.each do |mod|
104
+ mod[:schemas].each do |root|
105
+ paths.concat(closure_of(root, mod[:mapping], gaps))
106
+ end
107
+ end
108
+ [paths.compact.uniq, gaps]
109
+ end
110
+
111
+ def closure_of(root, mapping_path, gaps)
112
+ Expressir::Commands::ParityInputs.closure_paths(root,
113
+ stepmod: stepmod_root)
114
+ rescue StandardError => e
115
+ gaps << Entry.new(key: entry_key(mapping_path, "closure", e.message))
116
+ [root]
117
+ end
118
+
119
+ def stepmod_root
120
+ stepmod || documents_path.to_s
121
+ end
122
+
123
+ def drift_for(mod, repository)
124
+ mapping_path = mod[:mapping]
125
+ begin
126
+ document = Expressir::Mapping.load_file(mapping_path)
127
+ rescue StandardError => e
128
+ return [Entry.new(key: entry_key(mapping_path, "load", e.message))]
129
+ end
130
+
131
+ unknown = Expressir::Mapping.unknown_links(document, repository)
132
+ unknown.map do |link|
133
+ Entry.new(key: entry_key(mapping_path, "unknown_link", link.text))
134
+ end + refpath_issues(mapping_path, document, repository)
135
+ end
136
+
137
+ def refpath_issues(mapping_path, document, repository)
138
+ Expressir::Mapping.refpaths(document).flat_map do |location, content|
139
+ parsed = Expressir::Mapping::RefPath.parse(content)
140
+ parse_error_entries(mapping_path, location, parsed) +
141
+ validate_entries(mapping_path, location, parsed, repository)
142
+ end
143
+ end
144
+
145
+ def parse_error_entries(mapping_path, location, parsed)
146
+ parsed.parse_errors.map do |error|
147
+ Entry.new(key: entry_key(mapping_path, "refpath",
148
+ "#{location}: #{error}"))
149
+ end
150
+ end
151
+
152
+ def validate_entries(mapping_path, location, parsed, repository)
153
+ Expressir::Mapping::RefPath.validate(parsed, repository).map do |issue|
154
+ detail = "#{location} [step #{issue.step}]: #{issue.message}"
155
+ Entry.new(key: entry_key(mapping_path, "refpath", detail))
156
+ end
157
+ end
158
+
159
+ def entry_key(mapping_path, kind, detail)
160
+ "#{relative(mapping_path)}|#{kind}|#{detail}"
161
+ end
162
+
163
+ def relative(path)
164
+ Pathname.new(path).expand_path.relative_path_from(documents_path).to_s
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Null-object cache used when schema caching is disabled: every fetch misses
5
+ # and nothing is stored, so callers need no conditional guards around a cache.
6
+ class NullCache
7
+ def fetch(*, **) = nil
8
+
9
+ def store(*, **) = nil
10
+ end
11
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module Suma
7
+ # Content-addressed, on-disk cache of *generated* EXPRESS schema output --
8
+ # the plain (annotations stripped, ISO copyright kept) or annotated `.exp`
9
+ # text produced from a source schema.
10
+ #
11
+ # The cache key composes the source bytes, the Expressir version, and the
12
+ # annotation mode. Those three fully determine the output (Expressir's
13
+ # serialiser is deterministic), so a hit is guaranteed byte-identical to a
14
+ # fresh generation -- and the store can be shared across builds and git
15
+ # worktrees: identical source content hits regardless of which build wrote it.
16
+ #
17
+ # Supersedes the parsed-AST `Marshal` approach of metanorma/suma#35, which was
18
+ # brittle across Ruby/Expressir versions and cached the wrong artifact (the
19
+ # parse, not the output).
20
+ class SchemaCache
21
+ def initialize(directory)
22
+ @directory = directory.to_s
23
+ end
24
+
25
+ # The cached output for +source+, or +nil+ on a miss.
26
+ def fetch(source, annotations:)
27
+ path = entry_path(source, annotations: annotations)
28
+ return unless File.exist?(path)
29
+
30
+ File.read(path, encoding: "UTF-8")
31
+ end
32
+
33
+ # Cache +content+ as the output for +source+. Returns the entry path.
34
+ def store(source, annotations:, content:)
35
+ path = entry_path(source, annotations: annotations)
36
+ FileUtils.mkdir_p(@directory)
37
+ write_atomically(path, content)
38
+ path
39
+ end
40
+
41
+ private
42
+
43
+ def entry_path(source, annotations:)
44
+ File.join(@directory, key(source, annotations: annotations))
45
+ end
46
+
47
+ def key(source, annotations:)
48
+ mode = annotations ? "annotated" : "plain"
49
+ "#{Digest::SHA256.hexdigest(source)}-#{expressir_version}-#{mode}.exp"
50
+ end
51
+
52
+ def expressir_version
53
+ @expressir_version ||= Expressir::VERSION
54
+ end
55
+
56
+ # Write through a sibling temp file and rename, so a concurrent reader never
57
+ # observes a half-written entry.
58
+ def write_atomically(path, content)
59
+ temp = "#{path}.#{Process.pid}.tmp"
60
+ File.write(temp, content, encoding: "UTF-8")
61
+ File.rename(temp, path)
62
+ ensure
63
+ File.delete(temp) if temp && File.exist?(temp)
64
+ end
65
+ end
66
+ end
@@ -7,8 +7,9 @@ module Suma
7
7
  attr_accessor :config, :schemas, :docs, :output_path_docs,
8
8
  :output_path_schemas, :manifest
9
9
 
10
- def initialize(config: nil, config_yaml: nil, output_path_docs: nil,
11
- output_path_schemas: nil, manifest: nil)
10
+ def initialize(config: nil, config_yaml: nil, output_path_docs: nil, # rubocop:disable Metrics/ParameterLists
11
+ output_path_schemas: nil, manifest: nil,
12
+ options: { annotations: true })
12
13
  @schemas = {}
13
14
  @docs = {}
14
15
  @schema_name_to_docs = {}
@@ -19,6 +20,7 @@ module Suma
19
20
  @config = config
20
21
  @config ||= config_yaml && Expressir::SchemaManifest.from_file(config_yaml)
21
22
  @manifest = manifest
23
+ @options = options
22
24
  end
23
25
 
24
26
  def doc_from_schema_name(schema_name)
@@ -42,7 +44,7 @@ module Suma
42
44
  exporter = SchemaExporter.new(
43
45
  schemas: schemas.values,
44
46
  output_path: @output_path_schemas,
45
- options: { annotations: false },
47
+ options: @options,
46
48
  )
47
49
  exporter.export
48
50
 
@@ -23,6 +23,7 @@ module Suma
23
23
  @schemas = schemas
24
24
  @output_path = Pathname.new(output_path).expand_path
25
25
  @options = default_options.merge(options)
26
+ @cache = build_cache
26
27
  end
27
28
 
28
29
  def export
@@ -45,19 +46,51 @@ module Suma
45
46
  end
46
47
 
47
48
  def export_to_directory(schemas)
48
- schemas.each do |schema|
49
- schema.save_exp(with_annotations: options[:annotations])
50
- end
49
+ schemas.each { |schema| export_single_schema(schema) }
50
+ end
51
+
52
+ # Reuse cached plain/annotated output when the schema source is unchanged,
53
+ # otherwise generate it via +save_exp+ and cache the result. The Expressir
54
+ # parse (the cost) happens only on a cache miss. Caching is an orchestration
55
+ # concern, so it lives here in the service rather than in the ExpressSchema
56
+ # data model.
57
+ def export_single_schema(schema)
58
+ source = File.read(schema.path.to_s, encoding: "UTF-8")
59
+ cached = @cache.fetch(source, annotations: options[:annotations])
60
+ cached ? write_cached(schema, cached) : generate_and_cache(schema, source)
61
+ end
62
+
63
+ def generate_and_cache(schema, source)
64
+ schema.save_exp(with_annotations: options[:annotations])
65
+ content = File.read(schema.filename_plain)
66
+ @cache.store(source, annotations: options[:annotations], content: content)
67
+ end
68
+
69
+ def write_cached(schema, content)
70
+ relative = Pathname.new(schema.filename_plain).relative_path_from(Dir.pwd)
71
+ Utils.log "Save schema (cached): #{relative}"
72
+ FileUtils.mkdir_p(File.dirname(schema.filename_plain))
73
+ File.write(schema.filename_plain, content)
74
+ end
75
+
76
+ # A shared, content-addressed cache when a cache directory is configured
77
+ # (via the +:cache_dir+ option or the SUMA_SCHEMA_CACHE_DIR environment
78
+ # variable), otherwise a null cache (caching disabled).
79
+ def build_cache
80
+ directory = options[:cache_dir] || ENV.fetch("SUMA_SCHEMA_CACHE_DIR", nil)
81
+ return NullCache.new if directory.nil? || directory.empty?
82
+
83
+ SchemaCache.new(directory)
51
84
  end
52
85
 
53
- # rubocop:disable Metrics/MethodLength
86
+ # rubocop:disable-next Metrics/MethodLength
54
87
  def create_zip_archive
55
88
  require "zip"
56
89
 
57
90
  zip_path = "#{output_path}.zip"
58
91
  Utils.log "Creating ZIP archive: #{zip_path}"
59
92
 
60
- Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile|
93
+ Zip::File.open(zip_path, create: true) do |zipfile|
61
94
  Dir.glob("#{output_path}/**/*").each do |file|
62
95
  next if File.directory?(file)
63
96
 
@@ -68,6 +101,5 @@ module Suma
68
101
 
69
102
  Utils.log "ZIP archive created: #{zip_path}"
70
103
  end
71
- # rubocop:enable Metrics/MethodLength
72
104
  end
73
105
  end
@@ -5,8 +5,8 @@ module Suma
5
5
  module Formatters
6
6
  autoload :TerminalFormatter,
7
7
  "suma/svg_quality/formatters/terminal_formatter"
8
- autoload :JsonFormatter, "suma/svg_quality/formatters/json_formatter"
9
- autoload :YamlFormatter, "suma/svg_quality/formatters/yaml_formatter"
8
+ autoload :JsonFormatter, "suma/svg_quality/formatters/json_formatter"
9
+ autoload :YamlFormatter, "suma/svg_quality/formatters/yaml_formatter"
10
10
  end
11
11
  end
12
12
  end
data/lib/suma/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Suma
4
- VERSION = "0.5.2"
4
+ VERSION = "0.5.4"
5
5
  end
data/lib/suma.rb CHANGED
@@ -16,8 +16,11 @@ module Suma
16
16
  autoload :LinkValidation, "suma/link_validation"
17
17
  autoload :LinkValidator, "suma/link_validator"
18
18
  autoload :ManifestTraverser, "suma/manifest_traverser"
19
+ autoload :MappingDrift, "suma/mapping_drift"
19
20
  autoload :NoteProcessor, "suma/note_processor"
21
+ autoload :NullCache, "suma/null_cache"
20
22
  autoload :RegisterManifestGenerator, "suma/register_manifest_generator"
23
+ autoload :SchemaCache, "suma/schema_cache"
21
24
  autoload :SchemaCategory, "suma/schema_category"
22
25
  autoload :SchemaCollection, "suma/schema_collection"
23
26
  autoload :SchemaComparer, "suma/schema_comparer"
data/suma.gemspec CHANGED
@@ -33,13 +33,13 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength
33
33
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
34
  spec.require_paths = ["lib"]
35
35
 
36
- spec.add_dependency "expressir", ">= 2.1.29", "~> 2.1"
36
+ spec.add_dependency "expressir"
37
37
  spec.add_dependency "glossarist", "~> 2.8", ">= 2.8.7"
38
38
  spec.add_dependency "lutaml-model", "~> 0.8.0"
39
39
  spec.add_dependency "metanorma", "~> 2.3"
40
40
  spec.add_dependency "plurimath"
41
41
  spec.add_dependency "ruby-progressbar"
42
- spec.add_dependency "rubyzip", "~> 2.3"
42
+ spec.add_dependency "rubyzip"
43
43
  spec.add_dependency "svg_conform", "~> 0.1.0"
44
44
  spec.add_dependency "table_tennis"
45
45
  spec.add_dependency "thor", ">= 0.20"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: suma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: expressir
@@ -16,20 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 2.1.29
20
- - - "~>"
21
- - !ruby/object:Gem::Version
22
- version: '2.1'
19
+ version: '0'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
27
24
  - - ">="
28
25
  - !ruby/object:Gem::Version
29
- version: 2.1.29
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '2.1'
26
+ version: '0'
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: glossarist
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -110,16 +104,16 @@ dependencies:
110
104
  name: rubyzip
111
105
  requirement: !ruby/object:Gem::Requirement
112
106
  requirements:
113
- - - "~>"
107
+ - - ">="
114
108
  - !ruby/object:Gem::Version
115
- version: '2.3'
109
+ version: '0'
116
110
  type: :runtime
117
111
  prerelease: false
118
112
  version_requirements: !ruby/object:Gem::Requirement
119
113
  requirements:
120
- - - "~>"
114
+ - - ">="
121
115
  - !ruby/object:Gem::Version
122
- version: '2.3'
116
+ version: '0'
123
117
  - !ruby/object:Gem::Dependency
124
118
  name: svg_conform
125
119
  requirement: !ruby/object:Gem::Requirement
@@ -216,9 +210,12 @@ files:
216
210
  - lib/suma/link_validator.rb
217
211
  - lib/suma/link_validator/step.rb
218
212
  - lib/suma/manifest_traverser.rb
213
+ - lib/suma/mapping_drift.rb
219
214
  - lib/suma/note_processor.rb
215
+ - lib/suma/null_cache.rb
220
216
  - lib/suma/processor.rb
221
217
  - lib/suma/register_manifest_generator.rb
218
+ - lib/suma/schema_cache.rb
222
219
  - lib/suma/schema_category.rb
223
220
  - lib/suma/schema_collection.rb
224
221
  - lib/suma/schema_comparer.rb