suma 0.5.7 → 0.5.9

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-benchmark.yml +25 -0
  3. data/.github/workflows/release-provenance.yml +41 -0
  4. data/.rubocop_todo.yml +84 -0
  5. data/CLAUDE.md +44 -0
  6. data/README.adoc +136 -11
  7. data/Rakefile +13 -1
  8. data/bin/benchmark_corpus +138 -0
  9. data/bin/release_provenance +40 -0
  10. data/lib/suma/build_plan.rb +71 -0
  11. data/lib/suma/change_classification.rb +37 -0
  12. data/lib/suma/change_impact.rb +61 -0
  13. data/lib/suma/change_manifest.rb +147 -0
  14. data/lib/suma/cli/build.rb +23 -0
  15. data/lib/suma/cli/compare.rb +49 -2
  16. data/lib/suma/cli/core.rb +83 -0
  17. data/lib/suma/cli/export.rb +140 -10
  18. data/lib/suma/cli/extract_terms.rb +11 -0
  19. data/lib/suma/cli/generate_register.rb +12 -0
  20. data/lib/suma/cli/resolve.rb +45 -0
  21. data/lib/suma/cli/validate.rb +90 -0
  22. data/lib/suma/cli.rb +1 -0
  23. data/lib/suma/closure_validator.rb +317 -0
  24. data/lib/suma/corpus_graph.rb +141 -0
  25. data/lib/suma/express_schema.rb +22 -28
  26. data/lib/suma/null_cache.rb +4 -0
  27. data/lib/suma/owl_projection.rb +611 -0
  28. data/lib/suma/processor.rb +55 -6
  29. data/lib/suma/provenance.rb +159 -0
  30. data/lib/suma/publication.rb +108 -0
  31. data/lib/suma/register_manifest_generator.rb +76 -8
  32. data/lib/suma/schema_cache.rb +72 -13
  33. data/lib/suma/schema_category.rb +31 -2
  34. data/lib/suma/schema_collection.rb +58 -5
  35. data/lib/suma/schema_exporter.rb +5 -5
  36. data/lib/suma/schema_naming.rb +29 -33
  37. data/lib/suma/schema_profile.rb +134 -0
  38. data/lib/suma/term_classification.rb +9 -2
  39. data/lib/suma/term_extractor.rb +76 -35
  40. data/lib/suma/urn_resolver.rb +368 -0
  41. data/lib/suma/version.rb +1 -1
  42. data/lib/suma/worker_pool.rb +62 -0
  43. data/lib/suma.rb +12 -0
  44. metadata +19 -2
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "fileutils"
6
+ require "pathname"
7
+ require "expressir"
8
+
9
+ module Suma
10
+ # Build provenance: sha256 of every input and output artifact plus the
11
+ # toolchain versions that produced them.
12
+ #
13
+ # Paths are recorded relative to the working directory so a manifest can be
14
+ # moved with its build root. {.verify} recomputes the digests independently.
15
+ class Provenance
16
+ SECTIONS = %w[inputs outputs].freeze
17
+
18
+ # Sign a provenance manifest with an external tool.
19
+ #
20
+ # Optional by design: callers invoke this only when signing is requested.
21
+ # minisign uses a secret key (`key:`), sigstore/cosign signs with a key or
22
+ # keylessly when no key is given (ambient OIDC, e.g. GitHub Actions).
23
+ #
24
+ # @param path [String] provenance manifest
25
+ # @param method [String] minisign or sigstore
26
+ # @param key [String, nil] secret key path (minisign requires it)
27
+ # @return [String] signature artifact path
28
+ def self.sign(path, method:, key: nil)
29
+ binary, argv, signature = signing_command(
30
+ method: method, path: path, key: key,
31
+ )
32
+ sign_with(binary, argv)
33
+ signature
34
+ end
35
+
36
+ # The external command and signature path for a signing method. Pure
37
+ # apart from filesystem-free validation; exposed for cross-platform specs.
38
+ #
39
+ # @return [Array(String, Array<String>, String)] binary, argv, signature
40
+ def self.signing_command(method:, path:, key: nil)
41
+ case method.to_s
42
+ when "minisign"
43
+ unless key
44
+ raise ArgumentError, "minisign signing requires a secret key"
45
+ end
46
+
47
+ signature = "#{path}.minisig"
48
+ ["minisign", ["minisign", "-S", "-W", "-s", key, "-m", path,
49
+ "-x", signature], signature]
50
+ when "sigstore"
51
+ signature = "#{path}.bundle"
52
+ argv = ["cosign", "sign-blob", "--yes",
53
+ "--bundle", signature]
54
+ argv += ["--key", key] if key
55
+ argv << path
56
+ ["cosign", argv, signature]
57
+ else
58
+ raise ArgumentError, "unsupported signing method: #{method}"
59
+ end
60
+ end
61
+
62
+ # @return [Boolean] whether the signing tool is on PATH
63
+ def self.signing_available?(method)
64
+ case method.to_s
65
+ when "minisign" then executable?("minisign")
66
+ when "sigstore" then executable?("cosign")
67
+ else false
68
+ end
69
+ end
70
+
71
+ def self.sign_with(binary, argv)
72
+ unless executable?(binary)
73
+ raise Error, "#{binary} is not available on PATH"
74
+ end
75
+ return if system(*argv)
76
+
77
+ raise Error, "#{binary} signing failed: #{argv.join(' ')}"
78
+ end
79
+
80
+ def self.executable?(binary)
81
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
82
+ File.executable?(File.join(directory, binary))
83
+ end
84
+ end
85
+
86
+ # @return [Hash] toolchain versions that determine output bytes
87
+ def self.toolchain
88
+ {
89
+ "ruby" => RUBY_VERSION,
90
+ "expressir" => Expressir::VERSION,
91
+ "suma" => Suma::VERSION,
92
+ }.merge(optional_versions)
93
+ end
94
+
95
+ def self.optional_versions
96
+ versions = {}
97
+ versions["metanorma"] = Metanorma::VERSION if defined?(Metanorma::VERSION)
98
+ versions
99
+ end
100
+
101
+ # @param inputs [Array<String>] files or directories
102
+ # @param outputs [Array<String>] files or directories
103
+ # @return [Hash] manifest
104
+ def self.record(inputs:, outputs:)
105
+ {
106
+ "toolchain" => toolchain,
107
+ "inputs" => digests(inputs),
108
+ "outputs" => digests(outputs),
109
+ }
110
+ end
111
+
112
+ # Write a provenance manifest as canonical JSON.
113
+ def self.write(path, inputs:, outputs:)
114
+ FileUtils.mkdir_p(File.dirname(path))
115
+ manifest = record(inputs: inputs, outputs: outputs)
116
+ File.write(path, "#{JSON.pretty_generate(manifest)}\n")
117
+ path
118
+ end
119
+
120
+ # Independently recompute every digest in the manifest.
121
+ #
122
+ # @return [Array<String>] problems (empty = verified)
123
+ def self.verify(path)
124
+ manifest = JSON.parse(File.read(path))
125
+ SECTIONS.flat_map do |section|
126
+ manifest.fetch(section, {}).filter_map do |relative, digest|
127
+ full = File.expand_path(relative)
128
+ if !File.exist?(full)
129
+ "#{section}: missing #{relative}"
130
+ elsif Digest::SHA256.file(full).hexdigest != digest
131
+ "#{section}: changed #{relative}"
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # @param paths [Array<String>] files or directories
138
+ # @return [Hash{String=>String}] relative path → sha256
139
+ def self.digests(paths)
140
+ files = paths.flat_map do |path|
141
+ File.directory?(path) ? Dir.glob(File.join(path, "**/*")) : [path]
142
+ end
143
+ files.select { |file| File.file?(file) }
144
+ .map { |file| relative(file) }
145
+ .uniq.sort
146
+ .to_h { |file| [file, Digest::SHA256.file(file).hexdigest] }
147
+ end
148
+
149
+ def self.relative(path)
150
+ Pathname.new(File.expand_path(path))
151
+ .relative_path_from(Pathname.getwd)
152
+ .to_s
153
+ rescue ArgumentError
154
+ path.to_s
155
+ end
156
+
157
+ private_class_method :optional_versions, :sign_with, :executable?
158
+ end
159
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+
5
+ module Suma
6
+ # An application-protocol publication bundle: a lead schema (the AP's
7
+ # ARM/MIM) plus the transitive closure of the schemas it USEs FROM or
8
+ # REFERENCEs, resolved against a schema manifest.
9
+ #
10
+ # The closure is computed from the parsed EXPRESS interfaces
11
+ # (Expressir `Schema#interfaces`, `Interface#schema.id`), so the bundle
12
+ # mirrors what a STEP publication actually ships: one AP volume with
13
+ # every schema its AP needs.
14
+ #
15
+ # @example
16
+ # publication = Publication.new(
17
+ # lead_id: "ap242_foo_mim",
18
+ # schema_manifest_file: "schemas.yaml",
19
+ # )
20
+ # publication.schemas.each { |s| s.save_exp }
21
+ class Publication
22
+ attr_reader :lead_id, :schema_manifest_file, :profile
23
+
24
+ # @param lead_id [String] schema id of the publication's lead schema
25
+ # @param schema_manifest_file [String] path to the schema manifest
26
+ # @param profile [SchemaProfile, Symbol, String, nil] schema profile
27
+ def initialize(lead_id:, schema_manifest_file:, profile: nil)
28
+ @lead_id = lead_id
29
+ @schema_manifest_file = schema_manifest_file
30
+ @profile = SchemaProfile.fetch(profile)
31
+ end
32
+
33
+ # The bundle's schemas, lead first, then breadth-first over the
34
+ # interface closure.
35
+ #
36
+ # @param output_path [String] directory the caller will export into
37
+ # @return [Array<ExpressSchema>]
38
+ # @raise [Suma::SchemaNotFoundError] when the lead or a dependency is
39
+ # absent from the manifest
40
+ def schemas(output_path: ".")
41
+ entries = load_entries
42
+ resolve(lead_id, entries, output_path)
43
+ end
44
+
45
+ private
46
+
47
+ def load_entries
48
+ manifest = Expressir::SchemaManifest.from_file(
49
+ File.expand_path(schema_manifest_file),
50
+ )
51
+ manifest.schemas.to_h do |entry|
52
+ [entry.id, entry]
53
+ end
54
+ end
55
+
56
+ def resolve(lead, entries, output_path)
57
+ closure_ids(lead, entries).map do |id|
58
+ build_schema(id, entries, output_path)
59
+ end
60
+ end
61
+
62
+ # Breadth-first ids of the interface closure reachable from +lead+.
63
+ #
64
+ # @param lead [String]
65
+ # @param entries [Hash{String => Expressir::SchemaManifestEntry}]
66
+ # @return [Array<String>] lead first, then closure order
67
+ def closure_ids(lead, entries)
68
+ order = []
69
+ queue = [lead]
70
+ until queue.empty?
71
+ id = queue.shift
72
+ next if order.include?(id)
73
+
74
+ order << id
75
+ queue.concat(interface_ids(id, entries))
76
+ end
77
+ order
78
+ end
79
+
80
+ # Interface (USE FROM / REFERENCE) schema ids declared by one schema.
81
+ #
82
+ # @param id [String]
83
+ # @param entries [Hash{String => Expressir::SchemaManifestEntry}]
84
+ # @return [Array<String>]
85
+ def interface_ids(id, entries)
86
+ schema = build_schema(id, entries, ".")
87
+ dependency_ids(schema)
88
+ end
89
+
90
+ def build_schema(id, entries, output_path)
91
+ entry = entries.fetch(id) do
92
+ raise SchemaNotFoundError,
93
+ "[suma] schema #{id.inspect} (needed by publication " \
94
+ "#{lead_id.inspect}) not found in #{schema_manifest_file}"
95
+ end
96
+ ExpressSchema.new(id: id, path: entry.path,
97
+ output_path: output_path,
98
+ is_standalone_file: true,
99
+ profile: profile)
100
+ end
101
+
102
+ def dependency_ids(schema)
103
+ parsed = schema.parsed
104
+ schema_obj = parsed.schemas.first
105
+ (schema_obj.interfaces || []).filter_map { |i| i.schema&.id }.uniq
106
+ end
107
+ end
108
+ end
@@ -42,8 +42,14 @@ module Suma
42
42
  # @param ref [String] human-readable reference label
43
43
  # @param language_code [String] language for section names
44
44
  # @param owner [String] dataset owner organisation
45
+ # @param profile [SchemaProfile, Symbol, String, nil] schema profile
46
+ # governing classification, naming, and category ordering
47
+ # @param year [Integer, nil] edition year
48
+ # @param supersedes [String, nil] URN/ref this edition supersedes
49
+ # @param edition [Integer, nil] edition number (tagged as edition:N)
45
50
  def initialize(schema_manifest_file, output_path, urn:, id:, ref:,
46
- language_code: "eng", owner: DEFAULT_OWNER)
51
+ language_code: "eng", owner: DEFAULT_OWNER, profile: nil,
52
+ year: nil, supersedes: nil, edition: nil)
47
53
  @schema_manifest_file = File.expand_path(schema_manifest_file)
48
54
  @output_path = output_path
49
55
  @urn = Suma::Urn.new(urn)
@@ -51,6 +57,10 @@ module Suma
51
57
  @ref = ref
52
58
  @language_code = language_code
53
59
  @owner = owner
60
+ @profile = SchemaProfile.fetch(profile)
61
+ @year = year
62
+ @supersedes = supersedes
63
+ @edition = edition
54
64
  end
55
65
 
56
66
  # Generate and write register.yaml.
@@ -118,33 +128,91 @@ module Suma
118
128
  owner: @owner,
119
129
  languages: [@language_code],
120
130
  ordering: DEFAULT_ORDERING,
131
+ year: @year,
132
+ supersedes: @supersedes,
133
+ tags: edition_tags,
121
134
  sections: build_sections(schemas),
122
135
  )
123
136
  end
124
137
 
125
- # Build a list of top-level sections, one per category, in
126
- # SchemaCategory::ALL declaration order. Categories with no
127
- # schemas are omitted.
138
+ # Edition metadata for the register: the dataset tag groups sibling
139
+ # editions, and the edition number groups nothing on its own.
140
+ def edition_tags
141
+ tags = ["dataset:#{@id}"]
142
+ tags << "edition:#{@edition}" if @edition
143
+ tags
144
+ end
145
+
146
+ # Build a list of top-level sections, one per category, in the
147
+ # schema profile's category order. Categories with no schemas are
148
+ # omitted.
128
149
  #
129
150
  # @param schemas [Array<Expressir::SchemaManifestEntry>]
130
151
  # @return [Array<Glossarist::Section>]
131
152
  def build_sections(schemas)
132
153
  groups = schemas.group_by do |s|
133
- SchemaCategory.for_schema(id: s.id, path: s.path)
154
+ SchemaCategory.for_schema(id: s.id, path: s.path, profile: @profile)
134
155
  end
135
156
 
136
- SchemaCategory::ALL.filter_map do |category|
157
+ SchemaCategory.for_profile(@profile).filter_map do |category|
137
158
  children = groups[category] || []
138
159
  next if children.empty?
139
160
 
140
161
  Glossarist::Section.new(
141
162
  id: category.id,
142
163
  names: { @language_code => category.label },
143
- children: children.map { |s| build_section(s) },
164
+ children: section_children(category, children),
165
+ )
166
+ end
167
+ end
168
+
169
+ # Application protocols are grouped one level deeper: the schemas of
170
+ # one AP publication (its `_arm`/`_mim` pair) become the children of
171
+ # a per-AP parent section, so the register mirrors the publication
172
+ # bundling.
173
+ #
174
+ # @param category [SchemaCategory]
175
+ # @param schemas [Array<Expressir::SchemaManifestEntry>]
176
+ # @return [Array<Glossarist::Section>]
177
+ def section_children(category, schemas)
178
+ if category == SchemaCategory::APPLICATION_PROTOCOLS
179
+ build_ap_publication_sections(schemas)
180
+ else
181
+ schemas.map { |s| build_section(s) }
182
+ end
183
+ end
184
+
185
+ # Build one parent section per AP publication, keyed by the AP's
186
+ # base identity (the schema id with its ARM/MIM suffix stripped).
187
+ #
188
+ # @param schemas [Array<Expressir::SchemaManifestEntry>]
189
+ # @return [Array<Glossarist::Section>]
190
+ def build_ap_publication_sections(schemas)
191
+ schemas.group_by { |s| ap_base_id(s.id) }.sort_by { |base, _| base }
192
+ .map do |base, members|
193
+ Glossarist::Section.new(
194
+ id: base,
195
+ names: {
196
+ @language_code => "Application Protocol: " \
197
+ "#{SchemaNaming.display_name(base, profile: @profile)}",
198
+ },
199
+ children: members.sort_by { |s| s.id.downcase }
200
+ .map { |s| build_section(s) },
144
201
  )
145
202
  end
146
203
  end
147
204
 
205
+ # Strip the ARM/MIM suffix to get the AP publication's base identity.
206
+ #
207
+ # @param id [String]
208
+ # @return [String]
209
+ def ap_base_id(id)
210
+ %w[_arm _mim].each do |suffix|
211
+ return id.delete_suffix(suffix) if id.downcase.end_with?(suffix)
212
+ end
213
+ id
214
+ end
215
+
148
216
  # Build a single leaf section from a schema descriptor.
149
217
  #
150
218
  # @param schema [Expressir::SchemaManifestEntry]
@@ -154,7 +222,7 @@ module Suma
154
222
  id: schema.id,
155
223
  names: {
156
224
  @language_code => SchemaNaming.prefixed_name(
157
- schema.id, path: schema.path
225
+ schema.id, path: schema.path, profile: @profile
158
226
  ),
159
227
  },
160
228
  )
@@ -4,25 +4,45 @@ require "digest"
4
4
  require "fileutils"
5
5
 
6
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.
7
+ # Content-addressed, on-disk cache of build artifacts derived from EXPRESS
8
+ # source text.
10
9
  #
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.
10
+ # Two artifact kinds share one store:
16
11
  #
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).
12
+ # - generated `.exp` output (plain or annotated), keyed by source bytes,
13
+ # Expressir version, and annotation mode;
14
+ # - parsed Expressir repositories (S6.1), keyed by source bytes, Expressir
15
+ # version, Suma version, and Ruby's Marshal format version.
16
+ #
17
+ # Those inputs fully determine the artifact, so a hit is equivalent to a
18
+ # fresh build and the store can be shared across builds and worktrees.
19
+ # Parsed entries are Marshal payloads: only point the cache at a directory
20
+ # this process controls.
20
21
  class SchemaCache
22
+ # Bump when the persisted parsed-repository contract changes.
23
+ PARSED_FORMAT_VERSION = 1
24
+
25
+ attr_reader :hits, :misses
26
+
27
+ # Build the configured cache, or a null cache when caching is disabled.
28
+ #
29
+ # @param options [Hash] may carry :cache_dir
30
+ # @return [SchemaCache, NullCache]
31
+ def self.build(options = {})
32
+ directory = options[:cache_dir] || ENV.fetch("SUMA_SCHEMA_CACHE_DIR", nil)
33
+ return NullCache.new if directory.nil? || directory.empty?
34
+
35
+ new(directory)
36
+ end
37
+
21
38
  def initialize(directory)
22
39
  @directory = directory.to_s
40
+ @hits = 0
41
+ @misses = 0
42
+ @counter_lock = Mutex.new
23
43
  end
24
44
 
25
- # The cached output for +source+, or +nil+ on a miss.
45
+ # The cached EXPRESS output for +source+, or +nil+ on a miss.
26
46
  def fetch(source, annotations:)
27
47
  path = entry_path(source, annotations: annotations)
28
48
  return unless File.exist?(path)
@@ -38,26 +58,65 @@ module Suma
38
58
  path
39
59
  end
40
60
 
61
+ # The cached parsed repository for +source+, or +nil+ on a miss.
62
+ # Hit and miss counts support the "second build parses nothing" gate.
63
+ def fetch_parsed(source)
64
+ path = parsed_entry_path(source)
65
+ unless File.exist?(path)
66
+ @counter_lock.synchronize { @misses += 1 }
67
+ return nil
68
+ end
69
+
70
+ @counter_lock.synchronize { @hits += 1 }
71
+ # Local build cache only; never point SchemaCache at an untrusted path.
72
+ Marshal.load(File.binread(path)) # rubocop:disable Security/MarshalLoad
73
+ end
74
+
75
+ # Cache a parsed Expressir repository for +source+.
76
+ def store_parsed(source, repository)
77
+ path = parsed_entry_path(source)
78
+ FileUtils.mkdir_p(@directory)
79
+ write_atomically(path, Marshal.dump(repository))
80
+ path
81
+ end
82
+
41
83
  private
42
84
 
43
85
  def entry_path(source, annotations:)
44
86
  File.join(@directory, key(source, annotations: annotations))
45
87
  end
46
88
 
89
+ def parsed_entry_path(source)
90
+ File.join(@directory, parsed_key(source))
91
+ end
92
+
47
93
  def key(source, annotations:)
48
94
  mode = annotations ? "annotated" : "plain"
49
95
  "#{Digest::SHA256.hexdigest(source)}-#{expressir_version}-#{mode}.exp"
50
96
  end
51
97
 
98
+ def parsed_key(source)
99
+ "#{Digest::SHA256.hexdigest(source)}-#{expressir_version}-" \
100
+ "#{model_version}-#{marshal_version}.parsed"
101
+ end
102
+
52
103
  def expressir_version
53
104
  @expressir_version ||= Expressir::VERSION
54
105
  end
55
106
 
107
+ def model_version
108
+ @model_version ||= "#{Suma::VERSION}-#{PARSED_FORMAT_VERSION}"
109
+ end
110
+
111
+ def marshal_version
112
+ @marshal_version ||= "#{Marshal::MAJOR_VERSION}.#{Marshal::MINOR_VERSION}"
113
+ end
114
+
56
115
  # Write through a sibling temp file and rename, so a concurrent reader never
57
116
  # observes a half-written entry.
58
117
  def write_atomically(path, content)
59
118
  temp = "#{path}.#{Process.pid}.tmp"
60
- File.write(temp, content, encoding: "UTF-8")
119
+ File.binwrite(temp, content)
61
120
  File.rename(temp, path)
62
121
  ensure
63
122
  File.delete(temp) if temp && File.exist?(temp)
@@ -34,6 +34,13 @@ module Suma
34
34
  types: [ExpressSchema::Type::RESOURCE].freeze,
35
35
  directory: "resources",
36
36
  )
37
+ APPLICATION_PROTOCOLS = new(
38
+ id: "application_protocols",
39
+ label: "Application Protocols",
40
+ prefix: "Application Protocol",
41
+ types: [ExpressSchema::Type::APPLICATION_PROTOCOL].freeze,
42
+ directory: "applications",
43
+ )
37
44
  MODULES = new(
38
45
  id: "modules",
39
46
  label: "Application Modules",
@@ -65,18 +72,40 @@ module Suma
65
72
 
66
73
  ALL = [
67
74
  RESOURCES,
75
+ APPLICATION_PROTOCOLS,
68
76
  MODULES,
69
77
  BUSINESS_OBJECT_MODELS,
70
78
  CORE_MODEL,
71
79
  OTHER,
72
80
  ].freeze
73
81
 
82
+ BY_ID = ALL.to_h do |category|
83
+ [category.id, category]
84
+ end.freeze
85
+
74
86
  def self.for_type(type)
75
87
  ALL.find { |category| category.member?(type) } || OTHER
76
88
  end
77
89
 
78
- def self.for_schema(id:, path:)
79
- type = ExpressSchema::Type.classify(id: id, path: path)
90
+ # Look up a category by its slug; unknown ids fall back to OTHER so
91
+ # that schema profiles naming an unknown category still resolve.
92
+ #
93
+ # @param id [String, Symbol]
94
+ # @return [SchemaCategory]
95
+ def self.find(id)
96
+ BY_ID.fetch(id.to_s, OTHER)
97
+ end
98
+
99
+ # Ordered category list for a schema profile.
100
+ #
101
+ # @param profile [SchemaProfile, Symbol, String, nil]
102
+ # @return [Array<SchemaCategory>]
103
+ def self.for_profile(profile)
104
+ SchemaProfile.fetch(profile).category_ids.map { |id| find(id) }
105
+ end
106
+
107
+ def self.for_schema(id:, path:, profile: nil)
108
+ type = ExpressSchema::Type.classify(id: id, path: path, profile: profile)
80
109
  for_type(type)
81
110
  end
82
111
  end
@@ -6,6 +6,7 @@ module Suma
6
6
  class SchemaCollection
7
7
  attr_accessor :config, :schemas, :docs, :output_path_docs,
8
8
  :output_path_schemas, :manifest
9
+ attr_reader :cache
9
10
 
10
11
  def initialize(config: nil, config_yaml: nil, output_path_docs: nil, # rubocop:disable Metrics/ParameterLists
11
12
  output_path_schemas: nil, manifest: nil,
@@ -21,6 +22,7 @@ module Suma
21
22
  @config ||= config_yaml && Expressir::SchemaManifest.from_file(config_yaml)
22
23
  @manifest = manifest
23
24
  @options = options
25
+ @cache = SchemaCache.build(@options)
24
26
  end
25
27
 
26
28
  def doc_from_schema_name(schema_name)
@@ -40,21 +42,71 @@ module Suma
40
42
 
41
43
  def compile
42
44
  finalize
45
+ targets = rebuild_targets
43
46
 
44
47
  exporter = SchemaExporter.new(
45
- schemas: schemas.values,
48
+ schemas: targets ? schemas_for(targets) : schemas.values,
46
49
  output_path: @output_path_schemas,
47
50
  options: @options,
48
51
  )
49
52
  exporter.export
50
53
 
51
- docs.each_pair do |_schema_id, compiler|
52
- compiler.compile
53
- end
54
+ docs_for(targets).each_value(&:compile)
55
+ persist_build_state
56
+ end
57
+
58
+ # Schema ids an incremental build must re-render: changed schemas plus
59
+ # their transitive dependents. Returns nil when a full build is required
60
+ # (incremental disabled, outputs missing, or no prior state).
61
+ #
62
+ # Requires {#finalize} to have populated the collection.
63
+ #
64
+ # @return [Array<String>, nil]
65
+ def rebuild_targets
66
+ return nil unless @options[:incremental]
67
+ return nil unless outputs_present?
68
+
69
+ plan.rebuild
54
70
  end
55
71
 
56
72
  private
57
73
 
74
+ def plan
75
+ @plan ||= BuildPlan.new(
76
+ entries: @config.schemas,
77
+ graph: CorpusGraph.new(schemas.values.map(&:parsed)),
78
+ previous_state: BuildPlan.read_state(state_file),
79
+ )
80
+ end
81
+
82
+ def state_file
83
+ @options[:state_file] ||
84
+ File.join(@output_path_schemas, ".suma-build-state.json")
85
+ end
86
+
87
+ def outputs_present?
88
+ File.directory?(@output_path_docs) &&
89
+ File.directory?(@output_path_schemas)
90
+ end
91
+
92
+ def persist_build_state
93
+ return unless @options[:incremental]
94
+
95
+ BuildPlan.write_state(state_file, plan.current_state)
96
+ end
97
+
98
+ def schemas_for(targets)
99
+ wanted = targets.map(&:downcase)
100
+ schemas.values.select { |schema| wanted.include?(schema.id.downcase) }
101
+ end
102
+
103
+ def docs_for(targets)
104
+ return docs unless targets
105
+
106
+ wanted = targets.map(&:downcase)
107
+ docs.select { |id, _compiler| wanted.include?(id.downcase) }
108
+ end
109
+
58
110
  def process_schemas(schemas, template_class)
59
111
  schemas.each { |s| process_schema(s, template_class) }
60
112
  end
@@ -65,7 +117,8 @@ module Suma
65
117
  )
66
118
  express = ExpressSchema.new(
67
119
  id: config_schema.id, path: config_schema.path.to_s,
68
- output_path: @output_path_schemas.join(category.directory).to_s
120
+ output_path: @output_path_schemas.join(category.directory).to_s,
121
+ cache: @cache
69
122
  )
70
123
 
71
124
  compiler = SchemaCompiler.new(