suma 0.5.9 → 0.5.11

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/.rubocop_todo.yml +27 -0
  3. data/CLAUDE.md +17 -5
  4. data/README.adoc +101 -0
  5. data/lib/suma/anchor_validator.rb +85 -0
  6. data/lib/suma/artifact_server.rb +102 -0
  7. data/lib/suma/cli/artifact_server_cli.rb +22 -0
  8. data/lib/suma/cli/build.rb +20 -3
  9. data/lib/suma/cli/core.rb +50 -4
  10. data/lib/suma/cli/export.rb +1 -1
  11. data/lib/suma/cli/extract_terms.rb +1 -1
  12. data/lib/suma/cli/generate_register.rb +1 -1
  13. data/lib/suma/cli/ingest_translations.rb +36 -0
  14. data/lib/suma/cli/package_gcr.rb +51 -0
  15. data/lib/suma/cli/validate.rb +16 -0
  16. data/lib/suma/cli.rb +4 -1
  17. data/lib/suma/closure_validator.rb +141 -37
  18. data/lib/suma/corpus_graph.rb +80 -17
  19. data/lib/suma/gcr_package.rb +230 -0
  20. data/lib/suma/link_validation_result.rb +8 -0
  21. data/lib/suma/link_validator.rb +0 -3
  22. data/lib/suma/output_canonicalizer.rb +107 -0
  23. data/lib/suma/owl_projection.rb +3 -3
  24. data/lib/suma/processor.rb +100 -20
  25. data/lib/suma/provenance.rb +92 -12
  26. data/lib/suma/schema_collection.rb +1 -0
  27. data/lib/suma/schema_compiler.rb +79 -13
  28. data/lib/suma/schema_exporter.rb +12 -0
  29. data/lib/suma/schema_index.rb +5 -5
  30. data/lib/suma/schema_manifest_generator.rb +10 -3
  31. data/lib/suma/schema_profile.rb +12 -0
  32. data/lib/suma/schema_template/anchors.rb +154 -0
  33. data/lib/suma/schema_template/document.rb +2 -93
  34. data/lib/suma/schema_template/plain.rb +10 -6
  35. data/lib/suma/schema_template.rb +1 -0
  36. data/lib/suma/site_build.rb +153 -0
  37. data/lib/suma/staged_collection_builder.rb +146 -10
  38. data/lib/suma/term_extractor.rb +10 -4
  39. data/lib/suma/translation_ingester.rb +169 -0
  40. data/lib/suma/urn_resolver.rb +3 -3
  41. data/lib/suma/version.rb +1 -1
  42. data/lib/suma/worker_pool.rb +5 -1
  43. data/lib/suma.rb +7 -0
  44. metadata +13 -2
@@ -13,8 +13,10 @@ module Suma
13
13
 
14
14
  def initialize(metanorma_yaml_path:, schemas_all_path:, compile: true,
15
15
  output_directory: "_site", staged: false,
16
+ staged_workers: 1, staged_batch_size: nil,
16
17
  incremental: false, state_file: nil, jobs: 1,
17
- provenance_file: nil, sign_method: nil, sign_key: nil)
18
+ provenance_file: nil, sign_method: nil, sign_key: nil,
19
+ no_valid: false)
18
20
  @metanorma_yaml_path = metanorma_yaml_path
19
21
  @schemas_all_path = schemas_all_path
20
22
  @compile_flag = compile
@@ -22,12 +24,17 @@ module Suma
22
24
  # Opt-in memory-bounded staged build (metanorma/suma#94); the default
23
25
  # single-process build below is unchanged when false.
24
26
  @staged = staged
27
+ @staged_workers = staged_workers
28
+ @staged_batch_size = staged_batch_size
25
29
  @incremental = incremental
26
30
  @state_file = state_file
27
31
  @jobs = jobs
28
32
  @provenance_file = provenance_file
29
33
  @sign_method = sign_method
30
34
  @sign_key = sign_key
35
+ # Skip Metanorma document validation (metanorma `--no-valid`). Opt-in:
36
+ # for collections whose flavor's validators are still in flight.
37
+ @no_valid = no_valid
31
38
  end
32
39
 
33
40
  def run
@@ -39,7 +46,6 @@ module Suma
39
46
  Utils.log "Compiling schema collection..."
40
47
  compile_schema(schemas_all_path, collection_config)
41
48
 
42
- Utils.log "Compiling complete collection..."
43
49
  compile_collection(collection_config, output_directory)
44
50
  end
45
51
 
@@ -56,6 +62,7 @@ module Suma
56
62
  @provenance_file,
57
63
  inputs: provenance_inputs,
58
64
  outputs: provenance_outputs,
65
+ exclude: provenance_excludes,
59
66
  )
60
67
  if @sign_method
61
68
  signature = Provenance.sign(
@@ -80,6 +87,31 @@ module Suma
80
87
  @output_directory]
81
88
  end
82
89
 
90
+ # Build metadata that is not site content: the site freshness state, the
91
+ # staged-build store, and a provenance file written inside the output tree.
92
+ def provenance_excludes
93
+ excludes = [File.join(@output_directory, SiteBuild::STATE_FILE)]
94
+ if @staged
95
+ excludes << File.join(@output_directory,
96
+ StagedCollectionBuilder::STORE_DIRNAME)
97
+ excludes << File.join(@output_directory, "_staged_reinflate")
98
+ end
99
+ excludes + provenance_artifacts.select { |path| within_output?(path) }
100
+ end
101
+
102
+ def provenance_artifacts
103
+ return [] unless @provenance_file
104
+
105
+ base = File.expand_path(@provenance_file)
106
+ [base, "#{base}.minisig", "#{base}.bundle"]
107
+ end
108
+
109
+ def within_output?(path)
110
+ full = File.expand_path(path)
111
+ output = File.expand_path(@output_directory)
112
+ full == output || full.start_with?("#{output}#{File::SEPARATOR}")
113
+ end
114
+
83
115
  def export_schema_config
84
116
  site_config = Suma::SiteConfig::Config.from_file(metanorma_yaml_path)
85
117
 
@@ -108,6 +140,7 @@ module Suma
108
140
  incremental: @incremental,
109
141
  state_file: @state_file,
110
142
  jobs: @jobs,
143
+ no_valid: @no_valid,
111
144
  },
112
145
  )
113
146
 
@@ -115,36 +148,83 @@ module Suma
115
148
  end
116
149
 
117
150
  def compile_collection(collection_config, output_directory)
118
- metanorma_collection, collection_opts = build_collection(
119
- collection_config, output_directory
120
- )
151
+ prepare_collection_manifest(collection_config)
152
+ write_collection_manifest(collection_config)
153
+
154
+ if site_build.current?
155
+ Utils.log "Collection outputs are up to date; 0 documents rendered."
156
+ return
157
+ end
158
+
159
+ Utils.log "Rendering collection site..."
160
+ render_collection(collection_config, output_directory)
161
+ canonicalizer.canonicalize_documents
162
+ site_build.record
163
+ end
164
+
165
+ # Generated manifest identifiers are canonicalized in the model, before
166
+ # serialization: patching the dumped YAML would preserve Psych's
167
+ # value-dependent (random) quoting decision.
168
+ def prepare_collection_manifest(collection_config)
169
+ ManifestTraverser.new(collection_config.manifest)
170
+ .remove_schemas_only_sources
171
+ canonicalizer.canonicalize_manifest(collection_config.manifest)
172
+ end
121
173
 
174
+ def write_collection_manifest(collection_config)
175
+ collection_config.to_file(COLLECTION_OUTPUT_PATH)
176
+ end
177
+
178
+ def render_collection(collection_config, output_directory)
122
179
  if @staged
123
- # build_collection has written the emitted manifest; stage each member in
124
- # its own process, then reinflate. Bounds peak memory to a single member.
180
+ # The emitted manifest is the staging input; each member is compiled
181
+ # in its own process and then reinflated into +output_directory+.
125
182
  StagedCollectionBuilder.new(
126
183
  collection_config_path: COLLECTION_OUTPUT_PATH,
127
184
  output_directory: output_directory,
128
- coverpage: collection_opts[:coverpage],
185
+ coverpage: collection_coverpage(collection_config),
186
+ no_valid: @no_valid,
187
+ workers: @staged_workers,
188
+ batch_size: @staged_batch_size ||
189
+ StagedCollectionBuilder::DEFAULT_BATCH_SIZE,
129
190
  ).build
130
191
  else
131
- metanorma_collection.render(collection_opts)
192
+ Metanorma::Collection.parse(COLLECTION_OUTPUT_PATH)
193
+ .render(collection_options(collection_config, output_directory))
132
194
  end
133
195
  end
134
196
 
135
- def build_collection(collection_config, output_directory)
136
- new_collection_config_path = COLLECTION_OUTPUT_PATH
137
- ManifestTraverser.new(collection_config.manifest).remove_schemas_only_sources
138
- collection_config.to_file(new_collection_config_path)
139
-
140
- collection = Metanorma::Collection.parse(new_collection_config_path)
141
-
142
- collection_opts = {
197
+ def collection_options(collection_config, output_directory)
198
+ {
143
199
  output_folder: output_directory,
144
- compile: { install_fonts: false },
145
- coverpage: collection_config.coverpage || "cover.html",
200
+ compile: compile_flags,
201
+ coverpage: collection_coverpage(collection_config),
146
202
  }
147
- [collection, collection_opts]
203
+ end
204
+
205
+ def compile_flags
206
+ { install_fonts: false }.tap do |flags|
207
+ flags[:novalid] = true if @no_valid
208
+ end
209
+ end
210
+
211
+ def collection_coverpage(collection_config)
212
+ collection_config.coverpage || "cover.html"
213
+ end
214
+
215
+ def site_build
216
+ @site_build ||= SiteBuild.new(
217
+ root: File.dirname(File.expand_path(metanorma_yaml_path)),
218
+ output_directory: output_directory,
219
+ inputs: [schemas_all_path],
220
+ )
221
+ end
222
+
223
+ def canonicalizer
224
+ @canonicalizer ||= OutputCanonicalizer.new(
225
+ output_directory: output_directory,
226
+ sources: site_build.source_files,
227
+ )
148
228
  end
149
229
  end
150
230
  end
@@ -83,6 +83,9 @@ module Suma
83
83
  end
84
84
  end
85
85
 
86
+ # Gems whose versions determine rendered document bytes.
87
+ RENDER_GEM_PREFIXES = %w[metanorma- isodoc].freeze
88
+
86
89
  # @return [Hash] toolchain versions that determine output bytes
87
90
  def self.toolchain
88
91
  {
@@ -98,30 +101,63 @@ module Suma
98
101
  versions
99
102
  end
100
103
 
104
+ # Toolchain identity for cached rendered documents: the provenance
105
+ # toolchain plus every installed Metanorma/Isodoc gem, so a renderer or
106
+ # flavor update invalidates cached renders even when source is unchanged.
107
+ def self.render_toolchain
108
+ @render_toolchain ||= toolchain.merge("render" => render_gems)
109
+ end
110
+
111
+ def self.render_gems
112
+ gem_specs.select { |spec| render_gem?(spec.name) }
113
+ .sort_by { |spec| [spec.name, spec.version.to_s] }
114
+ .map { |spec| "#{spec.name}-#{spec.version}" }
115
+ end
116
+
117
+ def self.render_gem?(name)
118
+ name == "metanorma" || RENDER_GEM_PREFIXES.any? do |prefix|
119
+ name.start_with?(prefix)
120
+ end
121
+ end
122
+
123
+ def self.gem_specs
124
+ Gem::Specification.select { |spec| spec.name.match?(/\A(?:metanorma|isodoc)/) }
125
+ end
126
+
101
127
  # @param inputs [Array<String>] files or directories
102
128
  # @param outputs [Array<String>] files or directories
129
+ # @param exclude [Array<String>] files or directories omitted from the
130
+ # manifest and from the unrecorded-output check
103
131
  # @return [Hash] manifest
104
- def self.record(inputs:, outputs:)
132
+ def self.record(inputs:, outputs:, exclude: [])
133
+ excluded = exclude.map { |path| File.expand_path(path) }
105
134
  {
106
135
  "toolchain" => toolchain,
107
- "inputs" => digests(inputs),
108
- "outputs" => digests(outputs),
136
+ "inputs" => digests(inputs, exclude: excluded),
137
+ "outputs" => digests(outputs, exclude: excluded),
138
+ "output_roots" => output_roots(outputs, exclude: excluded),
139
+ "excluded" => excluded.map { |path| relative(path) }.sort,
109
140
  }
110
141
  end
111
142
 
112
143
  # Write a provenance manifest as canonical JSON.
113
- def self.write(path, inputs:, outputs:)
144
+ def self.write(path, inputs:, outputs:, exclude: [])
114
145
  FileUtils.mkdir_p(File.dirname(path))
115
- manifest = record(inputs: inputs, outputs: outputs)
146
+ manifest = record(inputs: inputs, outputs: outputs, exclude: exclude)
116
147
  File.write(path, "#{JSON.pretty_generate(manifest)}\n")
117
148
  path
118
149
  end
119
150
 
120
- # Independently recompute every digest in the manifest.
151
+ # Independently recompute every digest in the manifest and flag files
152
+ # that appeared under a recorded output root but are not in the manifest.
121
153
  #
122
154
  # @return [Array<String>] problems (empty = verified)
123
155
  def self.verify(path)
124
156
  manifest = JSON.parse(File.read(path))
157
+ digest_problems(manifest) + unrecorded_outputs(manifest)
158
+ end
159
+
160
+ def self.digest_problems(manifest)
125
161
  SECTIONS.flat_map do |section|
126
162
  manifest.fetch(section, {}).filter_map do |relative, digest|
127
163
  full = File.expand_path(relative)
@@ -134,18 +170,59 @@ module Suma
134
170
  end
135
171
  end
136
172
 
173
+ def self.unrecorded_outputs(manifest)
174
+ recorded = manifest.fetch("outputs", {})
175
+ excluded = expanded(manifest["excluded"])
176
+ Array(manifest["output_roots"]).flat_map do |root|
177
+ output_files(root)
178
+ .reject { |file| recorded.key?(file) || excluded?(file, excluded) }
179
+ .map { |file| "outputs: unrecorded #{file}" }
180
+ end.sort
181
+ end
182
+
183
+ def self.output_files(root)
184
+ full_root = File.expand_path(root)
185
+ return [] unless File.directory?(full_root)
186
+
187
+ files([full_root]).map { |file| relative(file) }.uniq
188
+ end
189
+
190
+ def self.expanded(paths)
191
+ Array(paths).map { |path| File.expand_path(path) }
192
+ end
193
+
194
+ def self.excluded?(relative_path, excluded)
195
+ full = File.expand_path(relative_path)
196
+ excluded.any? do |path|
197
+ full == path || full.start_with?("#{path}#{File::SEPARATOR}")
198
+ end
199
+ end
200
+
137
201
  # @param paths [Array<String>] files or directories
202
+ # @param exclude [Array<String>] expanded paths to skip
138
203
  # @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) }
204
+ def self.digests(paths, exclude: [])
205
+ files(paths)
206
+ .reject { |file| excluded?(file, exclude) }
144
207
  .map { |file| relative(file) }
145
208
  .uniq.sort
146
209
  .to_h { |file| [file, Digest::SHA256.file(file).hexdigest] }
147
210
  end
148
211
 
212
+ def self.files(paths)
213
+ paths.flat_map do |path|
214
+ File.directory?(path) ? Dir.glob(File.join(path, "**/*")) : [path]
215
+ end.select { |file| File.file?(file) }
216
+ end
217
+
218
+ # Relative paths of the directory entries among +outputs+.
219
+ def self.output_roots(outputs, exclude: [])
220
+ outputs.select { |path| File.directory?(path) }
221
+ .map { |path| relative(path) }
222
+ .reject { |path| excluded?(path, exclude) }
223
+ .uniq.sort
224
+ end
225
+
149
226
  def self.relative(path)
150
227
  Pathname.new(File.expand_path(path))
151
228
  .relative_path_from(Pathname.getwd)
@@ -154,6 +231,9 @@ module Suma
154
231
  path.to_s
155
232
  end
156
233
 
157
- private_class_method :optional_versions, :sign_with, :executable?
234
+ private_class_method :optional_versions, :sign_with, :executable?,
235
+ :render_gems, :render_gem?, :gem_specs,
236
+ :digest_problems, :unrecorded_outputs, :output_files,
237
+ :expanded, :excluded?, :files
158
238
  end
159
239
  end
@@ -125,6 +125,7 @@ module Suma
125
125
  schema: express,
126
126
  output_path: @output_path_docs.join(express.id),
127
127
  template: template_class.new(express.id),
128
+ no_valid: @options[:no_valid],
128
129
  )
129
130
 
130
131
  @docs[express.id] = compiler
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fileutils"
5
+ require "json"
4
6
  require "pathname"
5
7
 
6
8
  module Suma
@@ -12,20 +14,32 @@ module Suma
12
14
  # that the caller injects. This split means templates can be tested as pure
13
15
  # functions and the compiler can be tested with a real adoc fixture, without
14
16
  # either depending on the other.
17
+ #
18
+ # Rendering is skipped when the adoc, the schema config, the schema source,
19
+ # and the rendering toolchain are unchanged since the outputs were written:
20
+ # the render key is stored beside the outputs.
15
21
  class SchemaCompiler
22
+ RENDER_KEY_FILE = ".suma-render-key"
23
+
16
24
  attr_reader :schema, :id, :output_path, :template
17
25
 
18
- def initialize(schema:, output_path:, template:)
26
+ def initialize(schema:, output_path:, template:, no_valid: false)
19
27
  @schema = schema
20
28
  @id = schema.id
21
29
  @output_path = output_path
22
30
  @template = template
31
+ @no_valid = no_valid
23
32
  end
24
33
 
25
34
  def compile
26
35
  save_config
27
- save_adoc
36
+ adoc = template.render(config_relative_path)
37
+ save_adoc(adoc)
38
+ key = render_key(adoc)
39
+ return reuse_render if rendered_current?(key)
40
+
28
41
  invoke_metanorma
42
+ write_render_key(key)
29
43
  self
30
44
  end
31
45
 
@@ -37,6 +51,13 @@ module Suma
37
51
  template.extensions
38
52
  end
39
53
 
54
+ # Options handed to Metanorma::Compile. Exposed for specs.
55
+ def compile_options
56
+ { agree_to_terms: true, install_fonts: false }.tap do |options|
57
+ options[:novalid] = true if @no_valid
58
+ end
59
+ end
60
+
40
61
  private
41
62
 
42
63
  def filename_adoc(ext = "adoc")
@@ -47,15 +68,20 @@ module Suma
47
68
  File.join(output_path, "schema_#{id}.yaml")
48
69
  end
49
70
 
50
- def save_adoc
51
- log_relative "Save EXPRESS adoc", filename_adoc
52
-
53
- FileUtils.mkdir_p(File.dirname(filename_adoc))
71
+ def render_key_path
72
+ File.join(output_path, RENDER_KEY_FILE)
73
+ end
54
74
 
55
- config_relative = Pathname.new(filename_config)
75
+ def config_relative_path
76
+ Pathname.new(filename_config)
56
77
  .relative_path_from(Pathname.new(File.dirname(filename_adoc)))
78
+ end
79
+
80
+ def save_adoc(content)
81
+ log_relative "Save EXPRESS adoc", filename_adoc
57
82
 
58
- File.write(filename_adoc, template.render(config_relative))
83
+ FileUtils.mkdir_p(File.dirname(filename_adoc))
84
+ File.write(filename_adoc, content)
59
85
  end
60
86
 
61
87
  def save_config
@@ -68,16 +94,56 @@ module Suma
68
94
  config.save_to_path(filename_config)
69
95
  end
70
96
 
97
+ # Everything the rendered document depends on. The schema source is an
98
+ # input even though the adoc references it only by path.
99
+ def render_key(adoc)
100
+ Digest::SHA256.hexdigest(
101
+ [adoc, File.read(filename_config), schema_source_digest,
102
+ JSON.generate(Provenance.render_toolchain)].join("\0"),
103
+ )
104
+ end
105
+
106
+ def schema_source_digest
107
+ path = schema.path.to_s
108
+ File.file?(path) ? Digest::SHA256.file(path).hexdigest : "missing:#{path}"
109
+ end
110
+
111
+ def rendered_current?(key)
112
+ File.file?(render_key_path) && File.read(render_key_path) == key &&
113
+ expected_output_paths.all? { |path| File.file?(path) }
114
+ end
115
+
116
+ def expected_output_paths
117
+ extensions.split(",").map { |ext| filename_adoc(ext.strip) }
118
+ end
119
+
120
+ def write_render_key(key)
121
+ File.write(render_key_path, key)
122
+ end
123
+
124
+ def reuse_render
125
+ log_relative "Reusing rendered schema doc (id: #{id})", filename_adoc
126
+ self
127
+ end
128
+
71
129
  def invoke_metanorma
72
130
  log_relative "Compiling schema (id: #{id})", filename_adoc
73
- Metanorma::Compile.new.compile(
74
- filename_adoc,
75
- agree_to_terms: true,
76
- install_fonts: false,
77
- )
131
+ Metanorma::Compile.new.compile(filename_adoc, **compile_options)
132
+ verify_outputs!
78
133
  log_relative "Compiling schema (id: #{id}) ... done!", filename_adoc
79
134
  end
80
135
 
136
+ # Metanorma::Compile#compile can return nil for a handled failure instead
137
+ # of raising. Never cache a render that produced no output.
138
+ def verify_outputs!
139
+ missing = expected_output_paths.reject { |path| File.file?(path) }
140
+ return if missing.empty?
141
+
142
+ names = missing.map { |path| File.basename(path) }.join(", ")
143
+ raise CompilationError,
144
+ "Metanorma produced no #{names} for schema #{id}"
145
+ end
146
+
81
147
  def log_relative(prefix, path)
82
148
  relative = Pathname.new(path).relative_path_from(Dir.pwd)
83
149
  Utils.log "#{prefix}: #{relative}"
@@ -47,6 +47,18 @@ module Suma
47
47
  end
48
48
 
49
49
  def export_to_directory(schemas)
50
+ run_pool_export(schemas)
51
+ rescue SystemStackError
52
+ # One pathological schema (a very deep AST) can overflow the
53
+ # worker threads' ~1 MB stack. The main thread has the full OS
54
+ # stack and every completed export is cached, so the retry only
55
+ # pays for the schemas that did not finish.
56
+ Utils.log "[export] worker thread stack exhausted; " \
57
+ "retrying the remaining schemas on the main thread"
58
+ schemas.each { |schema| export_single_schema(schema) }
59
+ end
60
+
61
+ def run_pool_export(schemas)
50
62
  WorkerPool.new(size: options[:jobs]).map(schemas) do |schema|
51
63
  export_single_schema(schema)
52
64
  end
@@ -40,11 +40,11 @@ module Suma
40
40
 
41
41
  def element_collections(schema)
42
42
  [
43
- schema.entities,
44
- schema.types,
45
- schema.constants,
46
- schema.functions,
47
- schema.rules,
43
+ Array(schema.entities),
44
+ Array(schema.types),
45
+ Array(schema.constants),
46
+ Array(schema.functions),
47
+ Array(schema.rules),
48
48
  schema.procedures,
49
49
  schema.subtype_constraints,
50
50
  ]
@@ -48,11 +48,18 @@ exclude_paths: nil)
48
48
  YAML.safe_load(File.read(file_path, encoding: "UTF-8"), aliases: true)
49
49
  end
50
50
 
51
+ # Collection manifest files referenced by the site manifest. Both
52
+ # manifest shapes are handled: legacy `docref` entries and the
53
+ # nested `documents` entries (metanorma 2.x collection manifests).
54
+ # Only `file` references count — `fileref` entries are attachments
55
+ # (documents, schema exports), not collection manifests.
51
56
  def load_manifest_files(collection_files)
52
- collection_files.map do |c|
57
+ collection_files.flat_map do |c|
53
58
  collection_data = load_yaml(c)
54
- collection_data["manifest"]["docref"].map { |docref| docref["file"] }
55
- end.flatten
59
+ manifest = collection_data["manifest"] || {}
60
+ entries = Array(manifest["docref"]) + Array(manifest["documents"])
61
+ entries.filter_map { |entry| entry["file"] }
62
+ end
56
63
  end
57
64
 
58
65
  def load_project_schemas(manifest_files)
@@ -126,9 +126,21 @@ module Suma
126
126
  category_ids: %w[other],
127
127
  )
128
128
 
129
+ # buildingSMART IFC: one standalone schema per edition (IFC4, IFC4X3,
130
+ # ...), entities prefixed Ifc, no ARM/MIM-style decomposition.
131
+ IFC = new(
132
+ id: "ifc",
133
+ rules: [],
134
+ suffix_labels: {},
135
+ acronyms: %w[bim ifc idf 2d 3d 4d 5d],
136
+ acronym_patterns: [/\Aifc\d+(x\d+)*\z/],
137
+ category_ids: %w[other],
138
+ )
139
+
129
140
  REGISTRY = {
130
141
  "iso10303" => ISO_10303,
131
142
  "generic" => GENERIC,
143
+ "ifc" => IFC,
132
144
  }.freeze
133
145
  end
134
146
  end